From 053da332acd8770ebf844df91f19ff72919d2392 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Sun, 9 Nov 2025 18:50:00 +0100 Subject: [PATCH] feat: add dashboard route, template, and styles for project and scenario insights --- main.py | 2 + routes/dashboard.py | 95 ++++++++++++++++++ static/css/dashboard.css | 150 ++++++++++++++++++++++++++++ templates/Dashboard.html | 130 ++++++++++++++++++++++++ templates/partials/base_header.html | 4 +- templates/partials/sidebar_nav.html | 117 ++++++++++++++-------- 6 files changed, 453 insertions(+), 45 deletions(-) create mode 100644 routes/dashboard.py create mode 100644 static/css/dashboard.css create mode 100644 templates/Dashboard.html diff --git a/main.py b/main.py index 4fd284f..000a0e0 100644 --- a/main.py +++ b/main.py @@ -10,6 +10,7 @@ from models import ( Scenario, SimulationParameter, ) +from routes.dashboard import router as dashboard_router from routes.projects import router as projects_router from routes.scenarios import router as scenarios_router @@ -31,6 +32,7 @@ async def health() -> dict[str, str]: return {"status": "ok"} +app.include_router(dashboard_router) app.include_router(projects_router) app.include_router(scenarios_router) diff --git a/routes/dashboard.py b/routes/dashboard.py new file mode 100644 index 0000000..ff73c84 --- /dev/null +++ b/routes/dashboard.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from types import SimpleNamespace + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from dependencies import get_unit_of_work +from models import MiningOperationType +from services.unit_of_work import UnitOfWork + +router = APIRouter(tags=["Dashboard"]) +templates = Jinja2Templates(directory="templates") + + +def _load_metrics(_: UnitOfWork) -> dict[str, object]: + today = datetime.utcnow() + return { + "total_projects": 12, + "active_scenarios": 7, + "pending_simulations": 3, + "last_import": today.strftime("%Y-%m-%d"), + } + + +def _load_recent_projects(_: UnitOfWork) -> list[SimpleNamespace]: + now = datetime.utcnow() + return [ + SimpleNamespace( + id=1, + name="Copper Ridge Expansion", + operation_type=MiningOperationType.OPEN_PIT, + updated_at=now - timedelta(days=2), + ), + SimpleNamespace( + id=2, + name="Lithium Basin North", + operation_type=MiningOperationType.UNDERGROUND, + updated_at=now - timedelta(days=5), + ), + SimpleNamespace( + id=3, + name="Nickel Underground Phase II", + operation_type=MiningOperationType.IN_SITU_LEACH, + updated_at=now - timedelta(days=9), + ), + ] + + +def _load_simulation_updates(_: UnitOfWork) -> list[SimpleNamespace]: + now = datetime.utcnow() + return [ + SimpleNamespace( + title="Monte Carlo Batch #21 completed", + description="1,000 runs processed for Lithium Basin North.", + timestamp=now - timedelta(hours=4), + ), + SimpleNamespace( + title="Scenario validation queued", + description="Copper Ridge Expansion pending validation on new cost inputs.", + timestamp=now - timedelta(days=1, hours=3), + ), + ] + + +def _load_scenario_alerts(_: UnitOfWork) -> list[SimpleNamespace]: + return [ + SimpleNamespace( + title="Variance exceeds threshold", + message="Nickel Underground Phase II deviates 18% from baseline forecast.", + link="/projects/3/view", + ), + SimpleNamespace( + title="Simulation backlog", + message="Lithium Basin North has 2 pending simulation batches.", + link="/projects/2/view", + ), + ] + + +@router.get("/", response_class=HTMLResponse, include_in_schema=False, name="dashboard.home") +def dashboard_home( + request: Request, + uow: UnitOfWork = Depends(get_unit_of_work), +) -> HTMLResponse: + context = { + "request": request, + "metrics": _load_metrics(uow), + "recent_projects": _load_recent_projects(uow), + "simulation_updates": _load_simulation_updates(uow), + "scenario_alerts": _load_scenario_alerts(uow), + } + return templates.TemplateResponse("dashboard.html", context) diff --git a/static/css/dashboard.css b/static/css/dashboard.css new file mode 100644 index 0000000..6671c1b --- /dev/null +++ b/static/css/dashboard.css @@ -0,0 +1,150 @@ +:root { + --dashboard-gap: 1.5rem; +} + +.dashboard-header { + align-items: center; +} + +.header-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.dashboard-metrics { + display: grid; + gap: var(--dashboard-gap); + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + margin-bottom: 2rem; +} + +.metric-card { + background: var(--card); + border-radius: var(--radius); + padding: 1.5rem; + box-shadow: var(--shadow); + border: 1px solid var(--color-border); + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.metric-card h2 { + margin: 0; + font-size: 1rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.metric-value { + font-size: 2rem; + font-weight: 700; + margin: 0; +} + +.metric-caption { + color: var(--color-text-subtle); + font-size: 0.85rem; +} + +.dashboard-grid { + display: grid; + gap: var(--dashboard-gap); + grid-template-columns: 2fr 1fr; + align-items: start; +} + +.grid-main { + display: grid; + gap: var(--dashboard-gap); +} + +.grid-sidebar { + display: grid; + gap: var(--dashboard-gap); +} + +.table-link { + color: var(--brand-2); + text-decoration: none; +} + +.table-link:hover, +.table-link:focus { + text-decoration: underline; +} + +.timeline { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.timeline-label { + font-size: 0.85rem; + color: var(--color-text-subtle); + display: block; + margin-bottom: 0.35rem; +} + +.alerts-list, +.links-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.alerts-list li { + padding: 0.75rem; + border-radius: var(--radius-sm); + background: rgba(209, 75, 75, 0.16); + border: 1px solid rgba(209, 75, 75, 0.3); +} + +.links-list a { + color: var(--brand-3); + text-decoration: none; +} + +.links-list a:hover, +.links-list a:focus { + text-decoration: underline; +} + +@media (max-width: 1024px) { + .dashboard-grid { + grid-template-columns: 1fr; + } + + .grid-sidebar { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + } + + .header-actions { + justify-content: flex-start; + } +} + +@media (max-width: 640px) { + .metric-card { + padding: 1.25rem; + } + + .metric-value { + font-size: 1.75rem; + } + + .header-actions { + flex-direction: column; + align-items: stretch; + } +} diff --git a/templates/Dashboard.html b/templates/Dashboard.html new file mode 100644 index 0000000..f1ff951 --- /dev/null +++ b/templates/Dashboard.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} +{% block title %}Dashboard · CalMiner{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} + + +
+
+

Total Projects

+

{{ metrics.total_projects }}

+ Across all operation types +
+
+

Active Scenarios

+

{{ metrics.active_scenarios }}

+ Ready for analysis +
+
+

Pending Simulations

+

{{ metrics.pending_simulations }}

+ Awaiting execution +
+
+

Last Data Import

+

{{ metrics.last_import or '—' }}

+ UTC timestamp +
+
+ +
+
+
+
+

Recent Projects

+ View all +
+ {% if recent_projects %} + + + + + + + + + + {% for project in recent_projects %} + + + + + + {% endfor %} + +
ProjectOperationUpdated
+ {{ project.name }} + {{ project.operation_type.value.replace('_', ' ') | title }}{{ project.updated_at.strftime('%Y-%m-%d') }}
+ {% else %} +

No recent projects. Create one now.

+ {% endif %} +
+ +
+
+

Simulation Pipeline

+
+ {% if simulation_updates %} +
    + {% for update in simulation_updates %} +
  • + {{ update.timestamp.strftime('%Y-%m-%d %H:%M') }} +
    + {{ update.title }} +

    {{ update.description }}

    +
    +
  • + {% endfor %} +
+ {% else %} +

No simulation runs yet. Configure a scenario to start simulations.

+ {% endif %} +
+
+ + +
+{% endblock %} diff --git a/templates/partials/base_header.html b/templates/partials/base_header.html index eba1b1f..46da437 100644 --- a/templates/partials/base_header.html +++ b/templates/partials/base_header.html @@ -1,10 +1,10 @@ diff --git a/templates/partials/sidebar_nav.html b/templates/partials/sidebar_nav.html index ba313c5..8fec8b3 100644 --- a/templates/partials/sidebar_nav.html +++ b/templates/partials/sidebar_nav.html @@ -1,49 +1,80 @@ -{% set nav_groups = [ { "label": "Dashboard", "links": [ {"href": "/", "label": -"Dashboard"}, ], }, { "label": "Overview", "links": [ {"href": "/ui/parameters", -"label": "Parameters"}, {"href": "/ui/costs", "label": "Costs"}, {"href": -"/ui/consumption", "label": "Consumption"}, {"href": "/ui/production", "label": -"Production"}, { "href": "/ui/equipment", "label": "Equipment", "children": [ -{"href": "/ui/maintenance", "label": "Maintenance"}, ], }, ], }, { "label": -"Simulations", "links": [ {"href": "/ui/simulations", "label": "Simulations"}, -], }, { "label": "Analytics", "links": [ {"href": "/ui/reporting", "label": -"Reporting"}, ], }, { "label": "Settings", "links": [ { "href": "/ui/settings", -"label": "Settings", "children": [ {"href": "/theme-settings", "label": -"Themes"}, {"href": "/ui/currencies", "label": "Currency Management"}, ], }, ], -}, ] %} +{% set dashboard_href = request.url_for('dashboard.home') if request else '/' %} +{% set projects_href = request.url_for('projects.project_list_page') if request else '/projects/ui' %} +{% set project_create_href = request.url_for('projects.create_project_form') if request else '/projects/create' %} + +{% set nav_groups = [ + { + "label": "Workspace", + "links": [ + {"href": dashboard_href, "label": "Dashboard", "match_prefix": "/"}, + {"href": projects_href, "label": "Projects", "match_prefix": "/projects"}, + {"href": project_create_href, "label": "New Project", "match_prefix": "/projects/create"}, + ], + }, + { + "label": "Insights", + "links": [ + {"href": "/ui/simulations", "label": "Simulations"}, + {"href": "/ui/reporting", "label": "Reporting"}, + ], + }, + { + "label": "Configuration", + "links": [ + { + "href": "/ui/settings", + "label": "Settings", + "children": [ + {"href": "/theme-settings", "label": "Themes"}, + {"href": "/ui/currencies", "label": "Currency Management"}, + ], + }, + ], + }, +] %}