diff --git a/README.md b/README.md index 5f1ad4f..872172f 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ 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. +- **Unified UI Shell**: Server-rendered templates extend a shared base layout with a persistent left sidebar linking scenarios, parameters, costs, consumption, production, equipment, maintenance, simulations, and reporting views. +- **Operations Overview Dashboard**: The root route (`/`) surfaces cross-scenario KPIs, charts, and maintenance reminders with a one-click refresh backed by aggregated loaders. +- **Theming Tokens**: Shared CSS variables in `static/css/main.css` centralize the UI color palette for consistent styling and rapid theme tweaks. - **Monte Carlo Simulation (in progress)**: Services and routes are scaffolded for future stochastic analysis. ## Architecture @@ -79,19 +81,20 @@ 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 +- **UI entries** (rendered via FastAPI templates, also reachable from the sidebar): +- `GET /` operations overview dashboard + - `GET /ui/dashboard` legacy dashboard alias - `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 -1. Start the FastAPI server and navigate to `/ui/dashboard` (ensure `routes/ui.py` exposes this template or add a router that serves `templates/Dashboard.html`). -2. Use the "Load Sample Data" button to populate the JSON textarea with demo results. -3. Select "Refresh Dashboard" to post the dataset to `/api/reporting/summary` and render the returned statistics and distribution chart. -4. Paste your own simulation outputs (array of objects containing a numeric `result` property) to visualize custom runs; the endpoint expects the same schema used by the reporting service. -5. If the summary endpoint is unavailable, the dashboard displays an inline error—refresh once the API is reachable. +1. Start the FastAPI server and navigate to `/`. +2. Review the headline metrics, scenario snapshot table, and cost/activity charts sourced from the current database state. +3. Use the "Refresh Dashboard" button to pull freshly aggregated data via `/ui/dashboard/data` without reloading the page. +4. Populate scenarios, costs, production, consumption, simulations, and maintenance records to see charts and lists update. +5. The legacy `/ui/dashboard` route remains available but now serves the same consolidated overview. ## Testing diff --git a/docs/architecture.md b/docs/architecture.md index 18027d4..6fe411c 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 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. +- **Presentation** (`templates/`, `components/`): server-rendered views extend a shared `base.html` layout with a persistent left sidebar, 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. @@ -27,10 +27,11 @@ The backend leverages SQLAlchemy for ORM mapping to a PostgreSQL database. ### Dashboard Flow Review — 2025-10-20 -- The dashboard template depends on a future-facing HTML endpoint (e.g., `/dashboard`) that the current `routes/ui.py` router does not expose; wiring an explicit route is required before the page is reachable from the FastAPI app. -- Client-side logic calls `/api/reporting/summary` with raw simulation outputs and expects `result` fields, so any upstream changes to the reporting contract must maintain this schema. -- Initialization always loads the bundled sample data first, which is useful for demos but masks server errors—consider adding a failure banner when `/api/reporting/summary` is unavailable. -- No persistent storage backs the dashboard yet; users must paste or load JSON manually, aligning with the current MVP scope but highlighting an integration gap with the simulation results table. +- The dashboard now renders at the root route (`/`) and leverages `_load_dashboard` within `routes/ui.py` to aggregate scenarios, parameters, costs, production, consumption, simulations, and maintenance data before templating. +- Client-side logic consumes a server-rendered JSON payload and can request live refreshes via `GET /ui/dashboard/data`, ensuring charts and summary cards stay synchronized with the database without a full page reload. +- Chart.js visualises cost mix (CAPEX vs OPEX) and activity throughput (production vs consumption) per scenario. When datasets are empty the UI swaps the chart canvas for contextual guidance. +- Simulation metrics draw on the aggregated reporting service; once `simulation_result` persists records, the dashboard lists recent runs with iteration counts, mean values, and percentile highlights. +- Maintenance reminders pull the next five scheduled events, providing equipment and scenario context alongside formatted costs. ### Reporting Pipeline and UI Integration diff --git a/routes/ui.py b/routes/ui.py index a904786..71a2ac0 100644 --- a/routes/ui.py +++ b/routes/ui.py @@ -3,13 +3,21 @@ from datetime import datetime, timezone from typing import Any, Dict, Optional from fastapi import APIRouter, Depends, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session +from models.capex import Capex +from models.consumption import Consumption +from models.equipment import Equipment +from models.maintenance import Maintenance +from models.opex import Opex from models.parameters import Parameter +from models.production_output import ProductionOutput from models.scenario import Scenario +from models.simulation_result import SimulationResult from routes.dependencies import get_db +from services.reporting import generate_report router = APIRouter() @@ -35,6 +43,18 @@ def _render( return templates.TemplateResponse(template_name, _context(request, extra)) +def _format_currency(value: float) -> str: + return f"${value:,.2f}" + + +def _format_decimal(value: float) -> str: + return f"{value:,.2f}" + + +def _format_int(value: int) -> str: + return f"{value:,}" + + def _load_scenarios(db: Session) -> Dict[str, Any]: scenarios: list[Dict[str, Any]] = [ { @@ -62,32 +82,442 @@ def _load_parameters(db: Session) -> Dict[str, Any]: return {"parameters_by_scenario": dict(grouped)} -def _load_costs(_: Session) -> Dict[str, Any]: - return {"capex_entries": [], "opex_entries": []} +def _load_costs(db: Session) -> Dict[str, Any]: + capex_grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list) + for capex in ( + db.query(Capex) + .order_by(Capex.scenario_id, Capex.id) + .all() + ): + capex_grouped[int(getattr(capex, "scenario_id"))].append( + { + "id": int(getattr(capex, "id")), + "scenario_id": int(getattr(capex, "scenario_id")), + "amount": float(getattr(capex, "amount", 0.0)), + "description": getattr(capex, "description", "") or "", + } + ) + + opex_grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list) + for opex in ( + db.query(Opex) + .order_by(Opex.scenario_id, Opex.id) + .all() + ): + opex_grouped[int(getattr(opex, "scenario_id"))].append( + { + "id": int(getattr(opex, "id")), + "scenario_id": int(getattr(opex, "scenario_id")), + "amount": float(getattr(opex, "amount", 0.0)), + "description": getattr(opex, "description", "") or "", + } + ) + + return { + "capex_by_scenario": dict(capex_grouped), + "opex_by_scenario": dict(opex_grouped), + } -def _load_consumption(_: Session) -> Dict[str, Any]: - return {"consumption_records": []} +def _load_consumption(db: Session) -> Dict[str, Any]: + grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list) + for record in ( + db.query(Consumption) + .order_by(Consumption.scenario_id, Consumption.id) + .all() + ): + record_id = int(getattr(record, "id")) + scenario_id = int(getattr(record, "scenario_id")) + amount_value = float(getattr(record, "amount", 0.0)) + description = getattr(record, "description", "") or "" + grouped[scenario_id].append( + { + "id": record_id, + "scenario_id": scenario_id, + "amount": amount_value, + "description": description, + } + ) + return {"consumption_by_scenario": dict(grouped)} -def _load_production(_: Session) -> Dict[str, Any]: - return {"production_records": []} +def _load_production(db: Session) -> Dict[str, Any]: + grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list) + for record in ( + db.query(ProductionOutput) + .order_by(ProductionOutput.scenario_id, ProductionOutput.id) + .all() + ): + record_id = int(getattr(record, "id")) + scenario_id = int(getattr(record, "scenario_id")) + amount_value = float(getattr(record, "amount", 0.0)) + description = getattr(record, "description", "") or "" + grouped[scenario_id].append( + { + "id": record_id, + "scenario_id": scenario_id, + "amount": amount_value, + "description": description, + } + ) + return {"production_by_scenario": dict(grouped)} -def _load_equipment(_: Session) -> Dict[str, Any]: - return {"equipment_items": []} +def _load_equipment(db: Session) -> Dict[str, Any]: + grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list) + for record in ( + db.query(Equipment) + .order_by(Equipment.scenario_id, Equipment.id) + .all() + ): + record_id = int(getattr(record, "id")) + scenario_id = int(getattr(record, "scenario_id")) + name_value = getattr(record, "name", "") or "" + description = getattr(record, "description", "") or "" + grouped[scenario_id].append( + { + "id": record_id, + "scenario_id": scenario_id, + "name": name_value, + "description": description, + } + ) + return {"equipment_by_scenario": dict(grouped)} -def _load_maintenance(_: Session) -> Dict[str, Any]: - return {"maintenance_records": []} +def _load_maintenance(db: Session) -> Dict[str, Any]: + grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list) + for record in ( + db.query(Maintenance) + .order_by(Maintenance.scenario_id, Maintenance.maintenance_date) + .all() + ): + record_id = int(getattr(record, "id")) + scenario_id = int(getattr(record, "scenario_id")) + equipment_id = int(getattr(record, "equipment_id")) + equipment_obj = getattr(record, "equipment", None) + equipment_name = getattr( + equipment_obj, "name", "") if equipment_obj else "" + maintenance_date = getattr(record, "maintenance_date", None) + cost_value = float(getattr(record, "cost", 0.0)) + description = getattr(record, "description", "") or "" + + grouped[scenario_id].append( + { + "id": record_id, + "scenario_id": scenario_id, + "equipment_id": equipment_id, + "equipment_name": equipment_name, + "maintenance_date": maintenance_date.isoformat() if maintenance_date else "", + "cost": cost_value, + "description": description, + } + ) + return {"maintenance_by_scenario": dict(grouped)} -def _load_simulations(_: Session) -> Dict[str, Any]: - return {"simulation_runs": []} +def _load_simulations(db: Session) -> Dict[str, Any]: + scenarios: list[Dict[str, Any]] = [ + { + "id": item.id, + "name": item.name, + } + for item in db.query(Scenario).order_by(Scenario.name).all() + ] + + results_grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list) + for record in ( + db.query(SimulationResult) + .order_by(SimulationResult.scenario_id, SimulationResult.iteration) + .all() + ): + scenario_id = int(getattr(record, "scenario_id")) + results_grouped[scenario_id].append( + { + "iteration": int(getattr(record, "iteration")), + "result": float(getattr(record, "result", 0.0)), + } + ) + + runs: list[Dict[str, Any]] = [] + sample_limit = 20 + for item in scenarios: + scenario_id = int(item["id"]) + scenario_results = results_grouped.get(scenario_id, []) + summary = generate_report( + scenario_results) if scenario_results else generate_report([]) + runs.append( + { + "scenario_id": scenario_id, + "scenario_name": item["name"], + "iterations": int(summary.get("count", 0)), + "summary": summary, + "sample_results": scenario_results[:sample_limit], + } + ) + + return { + "simulation_scenarios": scenarios, + "simulation_runs": runs, + } -def _load_reporting(_: Session) -> Dict[str, Any]: - return {"report_summaries": []} +def _load_reporting(db: Session) -> Dict[str, Any]: + scenarios = _load_scenarios(db)["scenarios"] + runs = _load_simulations(db)["simulation_runs"] + + summaries: list[Dict[str, Any]] = [] + runs_by_scenario = {run["scenario_id"]: run for run in runs} + + for scenario in scenarios: + scenario_id = scenario["id"] + run = runs_by_scenario.get(scenario_id) + summary = run["summary"] if run else generate_report([]) + summaries.append( + { + "scenario_id": scenario_id, + "scenario_name": scenario["name"], + "summary": summary, + "iterations": run["iterations"] if run else 0, + } + ) + + return { + "report_summaries": summaries, + } + + +def _load_dashboard(db: Session) -> Dict[str, Any]: + scenarios = _load_scenarios(db)["scenarios"] + parameters_by_scenario = _load_parameters(db)["parameters_by_scenario"] + costs_context = _load_costs(db) + capex_by_scenario = costs_context["capex_by_scenario"] + opex_by_scenario = costs_context["opex_by_scenario"] + consumption_by_scenario = _load_consumption(db)["consumption_by_scenario"] + production_by_scenario = _load_production(db)["production_by_scenario"] + equipment_by_scenario = _load_equipment(db)["equipment_by_scenario"] + maintenance_by_scenario = _load_maintenance(db)["maintenance_by_scenario"] + simulation_context = _load_simulations(db) + simulation_runs = simulation_context["simulation_runs"] + + runs_by_scenario = { + run["scenario_id"]: run for run in simulation_runs + } + + def sum_amounts(grouped: Dict[int, list[Dict[str, Any]]], field: str = "amount") -> float: + total = 0.0 + for items in grouped.values(): + for item in items: + value = item.get(field, 0.0) + if isinstance(value, (int, float)): + total += float(value) + return total + + total_capex = sum_amounts(capex_by_scenario) + total_opex = sum_amounts(opex_by_scenario) + total_consumption = sum_amounts(consumption_by_scenario) + total_production = sum_amounts(production_by_scenario) + total_maintenance_cost = sum_amounts(maintenance_by_scenario, field="cost") + + total_parameters = sum(len(items) + for items in parameters_by_scenario.values()) + total_equipment = sum(len(items) + for items in equipment_by_scenario.values()) + total_maintenance_events = sum(len(items) + for items in maintenance_by_scenario.values()) + total_simulation_iterations = sum( + run["iterations"] for run in simulation_runs) + + scenario_rows: list[Dict[str, Any]] = [] + scenario_labels: list[str] = [] + scenario_capex: list[float] = [] + scenario_opex: list[float] = [] + activity_labels: list[str] = [] + activity_production: list[float] = [] + activity_consumption: list[float] = [] + + for scenario in scenarios: + scenario_id = scenario["id"] + scenario_name = scenario["name"] + param_count = len(parameters_by_scenario.get(scenario_id, [])) + equipment_count = len(equipment_by_scenario.get(scenario_id, [])) + maintenance_count = len(maintenance_by_scenario.get(scenario_id, [])) + + capex_total = sum( + float(item.get("amount", 0.0)) + for item in capex_by_scenario.get(scenario_id, []) + ) + opex_total = sum( + float(item.get("amount", 0.0)) + for item in opex_by_scenario.get(scenario_id, []) + ) + consumption_total = sum( + float(item.get("amount", 0.0)) + for item in consumption_by_scenario.get(scenario_id, []) + ) + production_total = sum( + float(item.get("amount", 0.0)) + for item in production_by_scenario.get(scenario_id, []) + ) + + run = runs_by_scenario.get(scenario_id) + summary = run["summary"] if run else generate_report([]) + iterations = run["iterations"] if run else 0 + mean_value = float(summary.get("mean", 0.0)) + + scenario_rows.append( + { + "scenario_name": scenario_name, + "parameter_count": param_count, + "parameter_display": _format_int(param_count), + "equipment_count": equipment_count, + "equipment_display": _format_int(equipment_count), + "capex_total": capex_total, + "capex_display": _format_currency(capex_total), + "opex_total": opex_total, + "opex_display": _format_currency(opex_total), + "production_total": production_total, + "production_display": _format_decimal(production_total), + "consumption_total": consumption_total, + "consumption_display": _format_decimal(consumption_total), + "maintenance_count": maintenance_count, + "maintenance_display": _format_int(maintenance_count), + "iterations": iterations, + "iterations_display": _format_int(iterations), + "simulation_mean": mean_value, + "simulation_mean_display": _format_decimal(mean_value), + } + ) + + scenario_labels.append(scenario_name) + scenario_capex.append(capex_total) + scenario_opex.append(opex_total) + + activity_labels.append(scenario_name) + activity_production.append(production_total) + activity_consumption.append(consumption_total) + + scenario_rows.sort(key=lambda row: row["scenario_name"].lower()) + + all_simulation_results = [ + {"result": float(getattr(item, "result", 0.0))} + for item in db.query(SimulationResult).all() + ] + overall_report = generate_report(all_simulation_results) + + overall_report_metrics = [ + {"label": "Runs", "value": _format_int( + int(overall_report.get("count", 0)))}, + {"label": "Mean", "value": _format_decimal( + float(overall_report.get("mean", 0.0)))}, + {"label": "Median", "value": _format_decimal( + float(overall_report.get("median", 0.0)))}, + {"label": "Std Dev", "value": _format_decimal( + float(overall_report.get("std_dev", 0.0)))}, + {"label": "95th Percentile", "value": _format_decimal( + float(overall_report.get("percentile_95", 0.0)))}, + {"label": "VaR (95%)", "value": _format_decimal( + float(overall_report.get("value_at_risk_95", 0.0)))}, + {"label": "Expected Shortfall (95%)", "value": _format_decimal( + float(overall_report.get("expected_shortfall_95", 0.0)))}, + ] + + recent_simulations: list[Dict[str, Any]] = [ + { + "scenario_name": run["scenario_name"], + "iterations": run["iterations"], + "iterations_display": _format_int(run["iterations"]), + "mean_display": _format_decimal(float(run["summary"].get("mean", 0.0))), + "p95_display": _format_decimal(float(run["summary"].get("percentile_95", 0.0))), + } + for run in simulation_runs + if run["iterations"] > 0 + ] + recent_simulations.sort(key=lambda item: item["iterations"], reverse=True) + recent_simulations = recent_simulations[:5] + + upcoming_maintenance = [] + for record in ( + db.query(Maintenance) + .order_by(Maintenance.maintenance_date.asc()) + .limit(5) + .all() + ): + maintenance_date = getattr(record, "maintenance_date", None) + upcoming_maintenance.append( + { + "scenario_name": getattr(getattr(record, "scenario", None), "name", "Unknown"), + "equipment_name": getattr(getattr(record, "equipment", None), "name", "Unknown"), + "date_display": maintenance_date.strftime("%Y-%m-%d") if maintenance_date else "—", + "cost_display": _format_currency(float(getattr(record, "cost", 0.0))), + "description": getattr(record, "description", "") or "—", + } + ) + + cost_chart_has_data = any(value > 0 for value in scenario_capex) or any( + value > 0 for value in scenario_opex + ) + activity_chart_has_data = any(value > 0 for value in activity_production) or any( + value > 0 for value in activity_consumption + ) + + scenario_cost_chart: Dict[str, list[Any]] = { + "labels": scenario_labels, + "capex": scenario_capex, + "opex": scenario_opex, + } + scenario_activity_chart: Dict[str, list[Any]] = { + "labels": activity_labels, + "production": activity_production, + "consumption": activity_consumption, + } + + summary_metrics = [ + {"label": "Active Scenarios", "value": _format_int(len(scenarios))}, + {"label": "Parameters", "value": _format_int(total_parameters)}, + {"label": "CAPEX Total", "value": _format_currency(total_capex)}, + {"label": "OPEX Total", "value": _format_currency(total_opex)}, + {"label": "Equipment Assets", "value": _format_int(total_equipment)}, + {"label": "Maintenance Events", + "value": _format_int(total_maintenance_events)}, + {"label": "Consumption", "value": _format_decimal(total_consumption)}, + {"label": "Production", "value": _format_decimal(total_production)}, + {"label": "Simulation Iterations", + "value": _format_int(total_simulation_iterations)}, + {"label": "Maintenance Cost", + "value": _format_currency(total_maintenance_cost)}, + ] + + return { + "summary_metrics": summary_metrics, + "scenario_rows": scenario_rows, + "overall_report_metrics": overall_report_metrics, + "recent_simulations": recent_simulations, + "upcoming_maintenance": upcoming_maintenance, + "scenario_cost_chart": scenario_cost_chart, + "scenario_activity_chart": scenario_activity_chart, + "cost_chart_has_data": cost_chart_has_data, + "activity_chart_has_data": activity_chart_has_data, + "report_available": overall_report.get("count", 0) > 0, + } + + +@router.get("/", response_class=HTMLResponse) +async def dashboard_root(request: Request, db: Session = Depends(get_db)): + """Render the primary dashboard landing page.""" + return _render(request, "Dashboard.html", _load_dashboard(db)) + + +@router.get("/ui/dashboard", response_class=HTMLResponse) +async def dashboard(request: Request, db: Session = Depends(get_db)): + """Render the legacy dashboard route for backward compatibility.""" + return _render(request, "Dashboard.html", _load_dashboard(db)) + + +@router.get("/ui/dashboard/data", response_class=JSONResponse) +async def dashboard_data(db: Session = Depends(get_db)) -> JSONResponse: + """Expose dashboard aggregates as JSON for client-side refreshes.""" + return JSONResponse(_load_dashboard(db)) @router.get("/ui/scenarios", response_class=HTMLResponse) @@ -97,12 +527,6 @@ async def scenario_form(request: Request, db: Session = Depends(get_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, db: Session = Depends(get_db)): """Render the parameter input form.""" @@ -114,41 +538,57 @@ async def parameter_form(request: Request, db: Session = Depends(get_db)): @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)) + """Render the costs view with CAPEX and OPEX data.""" + context: Dict[str, Any] = {} + context.update(_load_scenarios(db)) + context.update(_load_costs(db)) + return _render(request, "costs.html", context) @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)) + """Render the consumption view with scenario consumption data.""" + context: Dict[str, Any] = {} + context.update(_load_scenarios(db)) + context.update(_load_consumption(db)) + return _render(request, "consumption.html", context) @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)) + """Render the production view with scenario production data.""" + context: Dict[str, Any] = {} + context.update(_load_scenarios(db)) + context.update(_load_production(db)) + return _render(request, "production.html", context) @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)) + """Render the equipment view with scenario equipment data.""" + context: Dict[str, Any] = {} + context.update(_load_scenarios(db)) + context.update(_load_equipment(db)) + return _render(request, "equipment.html", context) @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)) + """Render the maintenance view with scenario maintenance data.""" + context: Dict[str, Any] = {} + context.update(_load_scenarios(db)) + context.update(_load_equipment(db)) + context.update(_load_maintenance(db)) + return _render(request, "maintenance.html", context) @router.get("/ui/simulations", response_class=HTMLResponse) async def simulations_view(request: Request, db: Session = Depends(get_db)): - """Render the simulations placeholder view.""" + """Render the simulations view with scenario information and recent runs.""" 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.""" + """Render the reporting view with scenario KPI summaries.""" return _render(request, "reporting.html", _load_reporting(db)) diff --git a/static/css/main.css b/static/css/main.css index 26cbc2f..c822ba7 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1,60 +1,326 @@ +:root { + --color-background: #f4f5f7; + --color-surface: #ffffff; + --color-text-primary: #1f2933; + --color-text-secondary: #475569; + --color-text-muted: #64748b; + --color-text-subtle: #94a3b8; + --color-text-invert: #ffffff; + --color-text-dark: #0f172a; + --color-text-strong: #111827; + --color-primary: #0b3d91; + --color-primary-strong: #2563eb; + --color-primary-stronger: #1d4ed8; + --color-accent: #38bdf8; + --color-border: #e2e8f0; + --color-border-strong: #cbd5e1; + --color-highlight: #eef2ff; + --color-panel-shadow: rgba(15, 23, 42, 0.08); + --color-panel-shadow-deep: rgba(15, 23, 42, 0.12); + --color-surface-alt: #f8fafc; + --color-success: #047857; + --color-error: #b91c1c; +} + body { margin: 0; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; - background-color: #f4f5f7; - color: #1f2933; + background-color: var(--color-background); + color: var(--color-text-primary); +} + +.app-layout { + display: flex; + min-height: 100vh; +} + +.app-sidebar { + width: 264px; + background-color: var(--color-primary); + color: var(--color-text-invert); + display: flex; + flex-direction: column; + box-shadow: 4px 0 16px var(--color-panel-shadow-deep); + position: sticky; + top: 0; + height: 100vh; +} + +.sidebar-inner { + display: flex; + flex-direction: column; + height: 100%; + padding: 2.5rem 1.75rem 1.75rem; + gap: 2rem; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: 1rem; +} + +.brand-logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 12px; + background: linear-gradient( + 135deg, + var(--color-primary-stronger), + var(--color-accent) + ); + color: var(--color-text-invert); + font-weight: 700; + font-size: 1.1rem; + letter-spacing: 1px; +} + +.brand-text { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.brand-title { + font-size: 1.35rem; + font-weight: 700; + margin: 0; +} + +.brand-subtitle { + font-size: 0.85rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.78); +} + +.sidebar-nav { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sidebar-link { + display: inline-flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 10px; + color: rgba(255, 255, 255, 0.88); + font-weight: 600; + text-decoration: none; + transition: background 0.2s ease, transform 0.2s ease, color 0.2s ease; +} + +.sidebar-link:hover, +.sidebar-link:focus { + background: rgba(148, 197, 255, 0.28); + color: var(--color-text-invert); + transform: translateX(4px); +} + +.sidebar-link.is-active { + background: rgba(148, 197, 255, 0.4); + color: var(--color-text-invert); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25); +} + +.app-main { + display: flex; + flex-direction: column; + flex: 1; + min-height: 100vh; +} + +.app-content { + flex: 1; + width: min(1120px, 92%); + margin: 0 auto; + padding: 2.5rem 0 2rem; } .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); +.app-content.container { + width: min(1120px, 92%); } -.header-inner { +.dashboard-header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; + gap: 1.5rem; + margin-bottom: 2rem; } -.site-title { - font-size: 1.5rem; +.dashboard-subtitle { + margin: 0.35rem 0 0; + color: var(--color-text-muted); +} + +.dashboard-actions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.dashboard-metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.metric-card { + background: var(--color-surface); + border-radius: 12px; + padding: 1.2rem 1.4rem; + box-shadow: 0 4px 14px var(--color-panel-shadow); + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.metric-label { + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); +} + +.metric-value { + font-size: 1.45rem; + font-weight: 700; + color: var(--color-text-dark); +} + +.dashboard-charts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 1.75rem; + margin-bottom: 2rem; +} + +.chart-card { + min-height: 320px; + position: relative; +} + +.chart-card canvas { + width: 100%; + height: 100%; +} + +.panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +.panel-header h3 { margin: 0; } -.site-nav { +.chart-subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-subtle); + font-size: 0.9rem; +} + +.dashboard-panel { + margin-bottom: 2rem; +} + +.dashboard-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 1.75rem; + margin-bottom: 2rem; +} + +.metric-list { + list-style: none; + margin: 0; + padding: 0; display: flex; - flex-wrap: wrap; + flex-direction: column; gap: 0.75rem; } -.site-nav a { - color: #ffffff; - text-decoration: none; - font-weight: 500; +.metric-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; } -.site-nav a:hover, -.site-nav a:focus { - text-decoration: underline; +.striped-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; } -main.container { - padding-top: 2rem; - padding-bottom: 2rem; +.striped-list li { + padding: 0.85rem 1rem; + border-radius: 10px; + background: var(--color-surface-alt); + box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.6); + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.striped-list li + li { + margin-top: 0.75rem; +} + +.list-title { + font-weight: 600; + color: var(--color-text-dark); +} + +.list-detail { + color: var(--color-text-secondary); + font-size: 0.95rem; +} + +.btn.is-loading { + opacity: 0.75; + pointer-events: none; +} + +.btn.is-loading::after { + content: ""; + width: 0.85rem; + height: 0.85rem; + border: 2px solid rgba(255, 255, 255, 0.6); + border-top-color: rgba(255, 255, 255, 1); + border-radius: 50%; + animation: spin 0.8s linear infinite; + display: inline-block; + margin-left: 0.6rem; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } } .panel { - background-color: #ffffff; + background-color: var(--color-surface); border-radius: 12px; padding: 1.5rem; - box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08); + box-shadow: 0 2px 8px var(--color-panel-shadow); margin-bottom: 2rem; } @@ -69,14 +335,14 @@ main.container { flex-direction: column; gap: 0.5rem; font-weight: 600; - color: #111827; + color: var(--color-text-strong); } .form-grid input, .form-grid textarea, .form-grid select { padding: 0.6rem 0.75rem; - border: 1px solid #cbd5e1; + border: 1px solid var(--color-border-strong); border-radius: 8px; font-size: 1rem; } @@ -84,7 +350,7 @@ main.container { .form-grid input:focus, .form-grid textarea:focus, .form-grid select:focus { - outline: 2px solid #2563eb; + outline: 2px solid var(--color-primary-strong); outline-offset: 1px; } @@ -98,30 +364,30 @@ main.container { border: none; cursor: pointer; font-weight: 600; - background-color: #e2e8f0; - color: #0f172a; + background-color: var(--color-border); + color: var(--color-text-dark); 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); + box-shadow: 0 4px 10px var(--color-panel-shadow); } .btn.primary { - background-color: #2563eb; - color: #ffffff; + background-color: var(--color-primary-strong); + color: var(--color-text-invert); } .btn.primary:hover, .btn.primary:focus { - background-color: #1d4ed8; + background-color: var(--color-primary-stronger); } .result-output { - background-color: #0f172a; - color: #f8fafc; + background-color: var(--color-text-dark); + color: var(--color-surface-alt); padding: 1rem; border-radius: 8px; font-family: "Fira Code", "Consolas", "Courier New", monospace; @@ -152,28 +418,28 @@ table { border-collapse: collapse; border-radius: 12px; overflow: hidden; - background-color: #f8fafc; + background-color: var(--color-surface-alt); } thead { - background-color: #0b3d91; - color: #ffffff; + background-color: var(--color-primary); + color: var(--color-text-invert); } th, td { padding: 0.75rem 1rem; text-align: left; - border-bottom: 1px solid #e2e8f0; + border-bottom: 1px solid var(--color-border); } tbody tr:nth-child(even) { - background-color: #eef2ff; + background-color: var(--color-highlight); } .empty-state { margin-top: 1.5rem; - color: #64748b; + color: var(--color-text-muted); font-style: italic; } @@ -187,16 +453,16 @@ tbody tr:nth-child(even) { } .feedback.success { - color: #047857; + color: var(--color-success); } .feedback.error { - color: #b91c1c; + color: var(--color-error); } .site-footer { - background-color: #0b3d91; - color: #ffffff; + background-color: var(--color-primary); + color: var(--color-text-invert); margin-top: 3rem; } @@ -207,3 +473,55 @@ tbody tr:nth-child(even) { padding: 1rem 0; font-size: 0.9rem; } + +@media (max-width: 1024px) { + .app-sidebar { + width: 240px; + } +} + +@media (max-width: 900px) { + .app-layout { + flex-direction: column; + } + + .app-sidebar { + position: relative; + width: 100%; + height: auto; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.14); + } + + .sidebar-inner { + padding: 1.5rem 1.25rem; + } + + .sidebar-brand { + justify-content: center; + } + + .sidebar-nav { + flex-direction: row; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + } + + .sidebar-link { + flex: 1 1 140px; + justify-content: center; + } + + .app-content { + width: min(100%, 94%); + padding-top: 2rem; + } + + .dashboard-charts { + grid-template-columns: 1fr; + } + + .dashboard-columns { + grid-template-columns: 1fr; + } +} diff --git a/templates/Dashboard.html b/templates/Dashboard.html index fecdcf5..dcd518a 100644 --- a/templates/Dashboard.html +++ b/templates/Dashboard.html @@ -1,261 +1,576 @@ {% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {% -block head_extra %} {{ super() }} - -{% endblock %} {% block content %} -
- Provide simulation outputs as JSON (array of objects containing the
- result field) and refresh the dashboard to preview metrics.
-