diff --git a/README.md b/README.md index e224a2e..5f1ad4f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A range of features are implemented to support these functionalities. - **Equipment Management**: Register scenario-specific equipment inventories. - **Maintenance Logging**: Log maintenance events against equipment with dates and costs. - **Reporting Dashboard**: Surface aggregated statistics for simulation outputs with an interactive Chart.js dashboard. +- **Unified UI Shell**: Server-rendered templates extend a shared base layout with navigation across scenarios, parameters, costs, consumption, production, equipment, maintenance, simulations, and reporting views. - **Monte Carlo Simulation (in progress)**: Services and routes are scaffolded for future stochastic analysis. ## Architecture @@ -78,6 +79,11 @@ uvicorn main:app --reload - `POST /api/equipment/` create equipment records - `POST /api/maintenance/` log maintenance events - `POST /api/reporting/summary` aggregate simulation results, returning count, mean/median, min/max, standard deviation, variance, percentile bands (5/10/90/95), value-at-risk (95%) and expected shortfall (95%) +- **UI entries** (rendered via FastAPI templates): + - `GET /ui/dashboard` reporting dashboard + - `GET /ui/scenarios` scenario creation form + - `GET /ui/parameters` parameter input form + - `GET /ui/costs`, `/ui/consumption`, `/ui/production`, `/ui/equipment`, `/ui/maintenance`, `/ui/simulations`, `/ui/reporting` placeholder views aligned with future integrations ### Dashboard Preview diff --git a/docs/architecture.md b/docs/architecture.md index cd1cfe8..18027d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,7 @@ The backend leverages SQLAlchemy for ORM mapping to a PostgreSQL database. - **FastAPI backend** (`main.py`, `routes/`): hosts REST endpoints for scenarios, parameters, costs, consumption, production, equipment, maintenance, simulations, and reporting. Each router encapsulates request/response schemas and DB access patterns, leveraging a shared dependency module (`routes/dependencies.get_db`) for SQLAlchemy session management. - **Service layer** (`services/`): houses business logic. `services/reporting.py` produces statistical summaries, while `services/simulation.py` provides the Monte Carlo integration point. - **Persistence** (`models/`, `config/database.py`): SQLAlchemy models map to PostgreSQL tables in schema `bricsium_platform`. Relationships connect scenarios to derived domain entities. -- **Presentation** (`templates/`, `components/`): server-rendered views support data entry (scenario and parameter forms) and the dashboard visualization powered by Chart.js. +- **Presentation** (`templates/`, `components/`): server-rendered views extend a shared `base.html` layout, pull global styles from `static/css/main.css`, and surface data entry (scenario and parameter forms) alongside the Chart.js-powered dashboard. - **Middleware** (`middleware/validation.py`): applies JSON validation before requests reach routers. - **Testing** (`tests/unit/`): pytest suite covering route and service behavior. @@ -90,3 +90,50 @@ For extended diagrams and setup instructions reference: - [docs/mvp.md](mvp.md) — roadmap and milestone scope. - [docs/implementation_plan.md](implementation_plan.md) — feature breakdown aligned with the TODO tracker. - [docs/architecture_overview.md](architecture_overview.md) — supplementary module map and request flow diagram. + +### UI Frontend-Backend Integration Requirements — 2025-10-20 + +#### Scenarios (`templates/ScenarioForm.html`) + +- **Data**: `GET /api/scenarios/` to list existing scenarios for navigation and to hydrate dropdowns in downstream forms; optional aggregation of scenario counts for dashboard badges. +- **Actions**: `POST /api/scenarios/` to create new scenarios; future delete/update flows would reuse the same router once endpoints exist. + +#### Parameters (`templates/ParameterInput.html`) + +- **Data**: Scenario catalogue from `GET /api/scenarios/`; parameter inventory via `GET /api/parameters/` with client-side filtering by `scenario_id`; optional distribution catalogue from `models/distribution` when exposed. +- **Actions**: `POST /api/parameters/` to add parameters; extend UI to support editing or deleting parameters as routes arrive. + +#### Costs (`templates/costs.html`) + +- **Data**: CAPEX list `GET /api/costs/capex`; OPEX list `GET /api/costs/opex`; computed totals grouped by scenario for summary panels. +- **Actions**: `POST /api/costs/capex` and `POST /api/costs/opex` for new entries; planned future edits/deletes once routers expand. + +#### Consumption (`templates/consumption.html`) + +- **Data**: Consumption entries by scenario via `GET /api/consumption/`; scenario metadata for filtering and empty-state messaging. +- **Actions**: `POST /api/consumption/` to log consumption items; include optimistic UI refresh after persistence. + +#### Production (`templates/production.html`) + +- **Data**: Production records from `GET /api/production/`; scenario list for filter chips; optional aggregates (totals, averages) derived client-side. +- **Actions**: `POST /api/production/` to capture production outputs; notify users when data drives downstream reporting. + +#### Equipment (`templates/equipment.html`) + +- **Data**: Equipment roster from `GET /api/equipment/`; scenario context to scope inventory; maintenance counts per asset once joins are introduced. +- **Actions**: `POST /api/equipment/` to add equipment; integrate delete/edit when endpoints arrive. + +#### Maintenance (`templates/maintenance.html`) + +- **Data**: Maintenance schedule `GET /api/maintenance/` with pagination support (`skip`, `limit`); equipment list (`GET /api/equipment/`) to map IDs to names; scenario catalogue for filtering. +- **Actions**: CRUD operations through `POST /api/maintenance/`, `PUT /api/maintenance/{id}`, `DELETE /api/maintenance/{id}`; view detail via `GET /api/maintenance/{id}` for modal display. + +#### Simulations (`templates/simulations.html`) + +- **Data**: Scenario list `GET /api/scenarios/`; parameter sets `GET /api/parameters/` filtered client-side; persisted simulation results (future) via dedicated endpoint or by caching `/api/simulations/run` responses. +- **Actions**: `POST /api/simulations/run` to execute simulations; surface run configuration (iterations, seed) and feed response summary into reporting page. + +#### Reporting (`templates/reporting.html` and `templates/Dashboard.html`) + +- **Data**: Simulation outputs either from recent `/api/simulations/run` calls or a planned `GET` endpoint against `simulation_result`; summary metrics via `POST /api/reporting/summary`; scenario metadata for header context. +- **Actions**: Trigger summary refreshes by posting batched results; allow export/download actions once implemented; integrate future comparison requests across scenarios. diff --git a/main.py b/main.py index adde7fe..ad006b4 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,10 @@ from routes.distributions import router as distributions_router from routes.ui import router as ui_router from routes.parameters import router as parameters_router -from fastapi import FastAPI -from fastapi.middleware import Middleware +from typing import Awaitable, Callable + +from fastapi import FastAPI, Request, Response +from fastapi.staticfiles import StaticFiles from middleware.validation import validate_json from config.database import Base, engine from routes.scenarios import router as scenarios_router @@ -18,8 +20,15 @@ from routes.maintenance import router as maintenance_router Base.metadata.create_all(bind=engine) app = FastAPI() -# Register validation middleware -app.middleware("http")(validate_json) + + +@app.middleware("http") +async def json_validation( + request: Request, call_next: Callable[[Request], Awaitable[Response]] +) -> Response: + return await validate_json(request, call_next) + +app.mount("/static", StaticFiles(directory="static"), name="static") # Include API routers app.include_router(scenarios_router) diff --git a/middleware/validation.py b/middleware/validation.py index 0819eaa..b779366 100644 --- a/middleware/validation.py +++ b/middleware/validation.py @@ -1,7 +1,10 @@ -from fastapi import Request, HTTPException +from typing import Awaitable, Callable +from fastapi import HTTPException, Request, Response -async def validate_json(request: Request, call_next): +MiddlewareCallNext = Callable[[Request], Awaitable[Response]] + +async def validate_json(request: Request, call_next: MiddlewareCallNext) -> Response: # Only validate JSON for requests with a body if request.method in ("POST", "PUT", "PATCH"): try: diff --git a/routes/ui.py b/routes/ui.py index b67b4af..a904786 100644 --- a/routes/ui.py +++ b/routes/ui.py @@ -1,6 +1,15 @@ -from fastapi import APIRouter, Request +from collections import defaultdict +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from models.parameters import Parameter +from models.scenario import Scenario +from routes.dependencies import get_db router = APIRouter() @@ -8,13 +17,138 @@ router = APIRouter() templates = Jinja2Templates(directory="templates") +def _context(request: Request, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "request": request, + "current_year": datetime.now(timezone.utc).year, + } + if extra: + payload.update(extra) + return payload + + +def _render( + request: Request, + template_name: str, + extra: Optional[Dict[str, Any]] = None, +): + return templates.TemplateResponse(template_name, _context(request, extra)) + + +def _load_scenarios(db: Session) -> Dict[str, Any]: + scenarios: list[Dict[str, Any]] = [ + { + "id": item.id, + "name": item.name, + "description": item.description, + } + for item in db.query(Scenario).order_by(Scenario.name).all() + ] + return {"scenarios": scenarios} + + +def _load_parameters(db: Session) -> Dict[str, Any]: + grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list) + for param in db.query(Parameter).order_by(Parameter.scenario_id, Parameter.id): + grouped[param.scenario_id].append( + { + "id": param.id, + "name": param.name, + "value": param.value, + "distribution_type": param.distribution_type, + "distribution_parameters": param.distribution_parameters, + } + ) + return {"parameters_by_scenario": dict(grouped)} + + +def _load_costs(_: Session) -> Dict[str, Any]: + return {"capex_entries": [], "opex_entries": []} + + +def _load_consumption(_: Session) -> Dict[str, Any]: + return {"consumption_records": []} + + +def _load_production(_: Session) -> Dict[str, Any]: + return {"production_records": []} + + +def _load_equipment(_: Session) -> Dict[str, Any]: + return {"equipment_items": []} + + +def _load_maintenance(_: Session) -> Dict[str, Any]: + return {"maintenance_records": []} + + +def _load_simulations(_: Session) -> Dict[str, Any]: + return {"simulation_runs": []} + + +def _load_reporting(_: Session) -> Dict[str, Any]: + return {"report_summaries": []} + + @router.get("/ui/scenarios", response_class=HTMLResponse) -async def scenario_form(request: Request): +async def scenario_form(request: Request, db: Session = Depends(get_db)): """Render the scenario creation form.""" - return templates.TemplateResponse("ScenarioForm.html", {"request": request}) + context = _load_scenarios(db) + return _render(request, "ScenarioForm.html", context) + + +@router.get("/ui/dashboard", response_class=HTMLResponse) +async def dashboard(request: Request): + """Render the reporting dashboard.""" + return _render(request, "Dashboard.html") @router.get("/ui/parameters", response_class=HTMLResponse) -async def parameter_form(request: Request): +async def parameter_form(request: Request, db: Session = Depends(get_db)): """Render the parameter input form.""" - return templates.TemplateResponse("ParameterInput.html", {"request": request}) + context: Dict[str, Any] = {} + context.update(_load_scenarios(db)) + context.update(_load_parameters(db)) + return _render(request, "ParameterInput.html", context) + + +@router.get("/ui/costs", response_class=HTMLResponse) +async def costs_view(request: Request, db: Session = Depends(get_db)): + """Render the costs placeholder view.""" + return _render(request, "costs.html", _load_costs(db)) + + +@router.get("/ui/consumption", response_class=HTMLResponse) +async def consumption_view(request: Request, db: Session = Depends(get_db)): + """Render the consumption placeholder view.""" + return _render(request, "consumption.html", _load_consumption(db)) + + +@router.get("/ui/production", response_class=HTMLResponse) +async def production_view(request: Request, db: Session = Depends(get_db)): + """Render the production placeholder view.""" + return _render(request, "production.html", _load_production(db)) + + +@router.get("/ui/equipment", response_class=HTMLResponse) +async def equipment_view(request: Request, db: Session = Depends(get_db)): + """Render the equipment placeholder view.""" + return _render(request, "equipment.html", _load_equipment(db)) + + +@router.get("/ui/maintenance", response_class=HTMLResponse) +async def maintenance_view(request: Request, db: Session = Depends(get_db)): + """Render the maintenance placeholder view.""" + return _render(request, "maintenance.html", _load_maintenance(db)) + + +@router.get("/ui/simulations", response_class=HTMLResponse) +async def simulations_view(request: Request, db: Session = Depends(get_db)): + """Render the simulations placeholder view.""" + return _render(request, "simulations.html", _load_simulations(db)) + + +@router.get("/ui/reporting", response_class=HTMLResponse) +async def reporting_view(request: Request, db: Session = Depends(get_db)): + """Render the reporting placeholder view.""" + return _render(request, "reporting.html", _load_reporting(db)) diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..26cbc2f --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,209 @@ +body { + margin: 0; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + background-color: #f4f5f7; + color: #1f2933; +} + +.container { + width: min(960px, 92%); + margin: 0 auto; + padding: 2rem 0; +} + +.site-header { + background-color: #0b3d91; + color: #ffffff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.header-inner { + display: flex; + align-items: center; + justify-content: space-between; +} + +.site-title { + font-size: 1.5rem; + margin: 0; +} + +.site-nav { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.site-nav a { + color: #ffffff; + text-decoration: none; + font-weight: 500; +} + +.site-nav a:hover, +.site-nav a:focus { + text-decoration: underline; +} + +main.container { + padding-top: 2rem; + padding-bottom: 2rem; +} + +.panel { + background-color: #ffffff; + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08); + margin-bottom: 2rem; +} + +.form-grid { + display: grid; + gap: 1rem; + max-width: 480px; +} + +.form-grid label { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-weight: 600; + color: #111827; +} + +.form-grid input, +.form-grid textarea, +.form-grid select { + padding: 0.6rem 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 8px; + font-size: 1rem; +} + +.form-grid input:focus, +.form-grid textarea:focus, +.form-grid select:focus { + outline: 2px solid #2563eb; + outline-offset: 1px; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.65rem 1.25rem; + border-radius: 999px; + border: none; + cursor: pointer; + font-weight: 600; + background-color: #e2e8f0; + color: #0f172a; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.btn:hover, +.btn:focus { + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(15, 23, 42, 0.1); +} + +.btn.primary { + background-color: #2563eb; + color: #ffffff; +} + +.btn.primary:hover, +.btn.primary:focus { + background-color: #1d4ed8; +} + +.result-output { + background-color: #0f172a; + color: #f8fafc; + padding: 1rem; + border-radius: 8px; + font-family: "Fira Code", "Consolas", "Courier New", monospace; + overflow-x: auto; + margin-top: 1.5rem; +} + +.monospace-input { + width: 100%; + font-family: "Fira Code", "Consolas", "Courier New", monospace; + min-height: 120px; +} + +.button-row { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 1rem; +} + +.table-container { + margin-top: 1.5rem; + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + border-radius: 12px; + overflow: hidden; + background-color: #f8fafc; +} + +thead { + background-color: #0b3d91; + color: #ffffff; +} + +th, +td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid #e2e8f0; +} + +tbody tr:nth-child(even) { + background-color: #eef2ff; +} + +.empty-state { + margin-top: 1.5rem; + color: #64748b; + font-style: italic; +} + +.hidden { + display: none; +} + +.feedback { + margin-top: 1rem; + font-weight: 600; +} + +.feedback.success { + color: #047857; +} + +.feedback.error { + color: #b91c1c; +} + +.site-footer { + background-color: #0b3d91; + color: #ffffff; + margin-top: 3rem; +} + +.footer-inner { + display: flex; + align-items: center; + justify-content: center; + padding: 1rem 0; + font-size: 0.9rem; +} diff --git a/templates/Dashboard.html b/templates/Dashboard.html index 30f4eab..fecdcf5 100644 --- a/templates/Dashboard.html +++ b/templates/Dashboard.html @@ -1,277 +1,261 @@ - - - - - CalMiner Dashboard - +{% endblock %} {% block content %} +

Simulation Results Dashboard

+
+

Summary Statistics

+
+ +
+
+

Sample Results Input

+

+ Provide simulation outputs as JSON (array of objects containing the + result field) and refresh the dashboard to preview metrics. +

+ +
+ + +
+
+
+

Result Distribution

+ +
+{% endblock %} {% block scripts %} {{ super() }} + + - - - + initializeDashboard(); + +{% endblock %} diff --git a/templates/ParameterInput.html b/templates/ParameterInput.html index 348fc12..245c726 100644 --- a/templates/ParameterInput.html +++ b/templates/ParameterInput.html @@ -1,50 +1,168 @@ - - - - - Process Parameters Input - - -

Enter Parameters for a Scenario

-
-
-
-
- -
-
- - - +{% extends "base.html" %} {% block title %}Process Parameters · CalMiner{% +endblock %} {% block content %} +
+

Scenario Parameters

+ {% if scenarios %} +
+ + + + +
+

+
+ + + + + + + + + + +
ParameterValueDistributionDetails
+
+ {% else %} +

+ No scenarios available. Create a scenario before adding parameters. +

+ {% endif %} +
+{% endblock %} {% block scripts %} {{ super() }} + + +{% endblock %} diff --git a/templates/ScenarioForm.html b/templates/ScenarioForm.html index c0412de..e659749 100644 --- a/templates/ScenarioForm.html +++ b/templates/ScenarioForm.html @@ -1,36 +1,114 @@ - - - - - Scenario Management - - -

Create a New Scenario

-
-
-
- -
-
- - - +{% extends "base.html" %} {% block title %}Scenario Management · CalMiner{% +endblock %} {% block content %} +
+

Create a New Scenario

+
+ + + +
+
+ {% if scenarios %} + + + + + + + + + {% for scenario in scenarios %} + + + + + {% endfor %} + +
NameDescription
{{ scenario.name }}{{ scenario.description or "—" }}
+ {% else %} +

+ No scenarios yet. Create one to get started. +

+ + + + + + + + + + {% endif %} +
+
+{% endblock %} {% block scripts %} {{ super() }} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..ec15084 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,17 @@ + + + + + + {% block title %}CalMiner{% endblock %} + + {% block head_extra %}{% endblock %} + + + {% include "partials/base_header.html" %} +
+ {% block content %}{% endblock %} +
+ {% include "partials/base_footer.html" %} {% block scripts %}{% endblock %} + + diff --git a/templates/consumption.html b/templates/consumption.html new file mode 100644 index 0000000..a19fc0e --- /dev/null +++ b/templates/consumption.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Consumption · CalMiner{% endblock %} +{% block content %} +
+

Consumption Tracking

+

Placeholder for tracking scenario consumption metrics. Integration with APIs coming soon.

+
+{% endblock %} diff --git a/templates/costs.html b/templates/costs.html new file mode 100644 index 0000000..e90b315 --- /dev/null +++ b/templates/costs.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Costs · CalMiner{% endblock %} +{% block content %} +
+

Costs

+

This view will surface CAPEX and OPEX entries tied to scenarios. API wiring pending.

+
+{% endblock %} diff --git a/templates/equipment.html b/templates/equipment.html new file mode 100644 index 0000000..2212c2e --- /dev/null +++ b/templates/equipment.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Equipment · CalMiner{% endblock %} +{% block content %} +
+

Equipment Inventory

+

Placeholder for equipment CRUD interface. To be wired to `/api/equipment/` routes.

+
+{% endblock %} diff --git a/templates/maintenance.html b/templates/maintenance.html new file mode 100644 index 0000000..2eb1c34 --- /dev/null +++ b/templates/maintenance.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Maintenance · CalMiner{% endblock %} +{% block content %} +
+

Maintenance Log

+

Placeholder for maintenance records management. Future work will surface CRUD flows tied to `/api/maintenance/`.

+
+{% endblock %} diff --git a/templates/partials/base_footer.html b/templates/partials/base_footer.html new file mode 100644 index 0000000..1d204e0 --- /dev/null +++ b/templates/partials/base_footer.html @@ -0,0 +1,5 @@ + diff --git a/templates/partials/base_header.html b/templates/partials/base_header.html new file mode 100644 index 0000000..aff5df1 --- /dev/null +++ b/templates/partials/base_header.html @@ -0,0 +1,17 @@ + diff --git a/templates/production.html b/templates/production.html new file mode 100644 index 0000000..959033e --- /dev/null +++ b/templates/production.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Production · CalMiner{% endblock %} +{% block content %} +
+

Production Output

+

Placeholder for production metrics per scenario. Hook up to `/api/production/` endpoints.

+
+{% endblock %} diff --git a/templates/reporting.html b/templates/reporting.html new file mode 100644 index 0000000..4a703c2 --- /dev/null +++ b/templates/reporting.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Reporting · CalMiner{% endblock %} +{% block content %} +
+

Reporting

+

Placeholder for aggregated KPI views connected to `/api/reporting/` endpoints.

+
+{% endblock %} diff --git a/templates/simulations.html b/templates/simulations.html new file mode 100644 index 0000000..e9e66d1 --- /dev/null +++ b/templates/simulations.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block title %}Simulations · CalMiner{% endblock %} +{% block content %} +
+

Monte Carlo Simulations

+

Placeholder for running simulations and reviewing outputs. Target integration: `/api/simulations/run`.

+
+{% endblock %}