feat: Enhance equipment, maintenance, production, and simulation management interfaces

- Updated equipment.html to include scenario filtering, equipment addition form, and dynamic equipment listing.
- Enhanced maintenance.html with scenario filtering, maintenance entry form, and dynamic equipment selection.
- Improved production.html to allow scenario filtering and production output entry.
- Revamped reporting.html to display scenario KPI summaries with refresh functionality.
- Expanded simulations.html to support scenario selection, simulation run history, and detailed results display.
- Refactored base_header.html for improved navigation structure and active link highlighting.
This commit is contained in:
2025-10-21 00:09:00 +02:00
parent 5ecd2b8d19
commit 5a84445e90
14 changed files with 3299 additions and 381 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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;
}
}

View File

@@ -1,195 +1,517 @@
{% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {%
block head_extra %} {{ super() }}
<style>
.summary-card {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
padding: 1.5rem;
margin-bottom: 2rem;
}
.summary-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.metric {
text-align: center;
}
.metric-label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #52606d;
}
.metric-value {
font-size: 1.4rem;
font-weight: bold;
margin-top: 0.4rem;
}
#chart-container {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
padding: 1.5rem;
}
#error-message {
color: #b91d47;
margin-top: 1rem;
}
</style>
{% endblock %} {% block content %}
<h2>Simulation Results Dashboard</h2>
<div class="summary-card">
<h3>Summary Statistics</h3>
<div id="summary-grid" class="summary-grid"></div>
<p id="error-message" hidden></p>
</div>
<div class="summary-card">
<h3>Sample Results Input</h3>
<p>
Provide simulation outputs as JSON (array of objects containing the
<code>result</code> field) and refresh the dashboard to preview metrics.
block content %}
<div class="dashboard-header">
<div>
<h2>Operations Overview</h2>
<p class="dashboard-subtitle">
Unified insight across scenarios, costs, production, maintenance, and
simulations.
</p>
<textarea id="results-input" rows="6" class="monospace-input"></textarea>
<div class="button-row">
<button id="load-sample" type="button" class="btn">Load Sample Data</button>
</div>
<div class="dashboard-actions">
<button id="refresh-dashboard" type="button" class="btn primary">
Refresh Dashboard
</button>
</div>
</div>
<div id="chart-container">
<h3>Result Distribution</h3>
<canvas id="summary-chart" height="120"></canvas>
<p id="dashboard-status" class="feedback" hidden></p>
<section>
<div id="summary-metrics" class="dashboard-metrics-grid">
{% for metric in summary_metrics %}
<article class="metric-card">
<span class="metric-label">{{ metric.label }}</span>
<span class="metric-value">{{ metric.value }}</span>
</article>
{% endfor %}
</div>
<p id="summary-empty" class="empty-state" {% if summary_metrics|length>
0 %} hidden{% endif %}> Add project inputs to populate summary metrics.
</p>
</section>
<section class="dashboard-charts">
<article class="panel chart-card">
<header class="panel-header">
<div>
<h3>Scenario Cost Mix</h3>
<p class="chart-subtitle">CAPEX vs OPEX totals per scenario</p>
</div>
</header>
<canvas
id="cost-chart"
height="220"
{%
if
not
cost_chart_has_data
%}
hidden{%
endif
%}
></canvas>
<p
id="cost-chart-empty"
class="empty-state"
{%
if
cost_chart_has_data
%}
hidden{%
endif
%}
>
Add CAPEX or OPEX entries to display this chart.
</p>
</article>
<article class="panel chart-card">
<header class="panel-header">
<div>
<h3>Production vs Consumption</h3>
<p class="chart-subtitle">Throughput comparison by scenario</p>
</div>
</header>
<canvas
id="activity-chart"
height="220"
{%
if
not
activity_chart_has_data
%}
hidden{%
endif
%}
></canvas>
<p
id="activity-chart-empty"
class="empty-state"
{%
if
activity_chart_has_data
%}
hidden{%
endif
%}
>
Add production or consumption records to display this chart.
</p>
</article>
</section>
<section class="panel dashboard-panel">
<header class="panel-header">
<div>
<h3>Scenario Snapshot</h3>
<p class="chart-subtitle">
Operational and financial highlights per scenario
</p>
</div>
</header>
<div class="table-container">
<table id="scenario-table">
<thead>
<tr>
<th>Scenario</th>
<th>Parameters</th>
<th>Equipment</th>
<th>CAPEX</th>
<th>OPEX</th>
<th>Production</th>
<th>Consumption</th>
<th>Maintenance</th>
<th>Iterations</th>
<th>Simulation Mean</th>
</tr>
</thead>
<tbody>
{% for row in scenario_rows %}
<tr>
<td>{{ row.scenario_name }}</td>
<td>{{ row.parameter_display }}</td>
<td>{{ row.equipment_display }}</td>
<td>{{ row.capex_display }}</td>
<td>{{ row.opex_display }}</td>
<td>{{ row.production_display }}</td>
<td>{{ row.consumption_display }}</td>
<td>{{ row.maintenance_display }}</td>
<td>{{ row.iterations_display }}</td>
<td>{{ row.simulation_mean_display }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p id="scenario-table-empty" class="empty-state" {% if scenario_rows|length>
0 %} hidden{% endif %}> Create scenarios and populate domain data to see the
snapshot overview.
</p>
</section>
<div class="dashboard-columns">
<section class="panel dashboard-panel">
<header class="panel-header">
<h3>Overall Simulation Metrics</h3>
</header>
<ul id="overall-metrics" class="metric-list">
{% for metric in overall_report_metrics %}
<li>
<span class="metric-label">{{ metric.label }}</span>
<span class="metric-value">{{ metric.value }}</span>
</li>
{% endfor %}
</ul>
<p
id="overall-metrics-empty"
class="empty-state"
{%
if
report_available
%}
hidden{%
endif
%}
>
Run a simulation to surface aggregate reporting metrics.
</p>
</section>
<section class="panel dashboard-panel">
<header class="panel-header">
<h3>Recent Simulation Runs</h3>
</header>
<ul id="recent-simulations" class="striped-list">
{% for run in recent_simulations %}
<li>
<span class="list-title">{{ run.scenario_name }}</span>
<span class="list-detail"
>Iterations: {{ run.iterations_display }} · Mean: {{ run.mean_display
}} · P95: {{ run.p95_display }}</span
>
</li>
{% endfor %}
</ul>
<p
id="recent-simulations-empty"
class="empty-state"
{%
if
recent_simulations|length
>
0 %} hidden{% endif %}> Trigger simulations to populate recent run
statistics.
</p>
</section>
</div>
<section class="panel dashboard-panel">
<header class="panel-header">
<h3>Upcoming Maintenance</h3>
</header>
<ul id="upcoming-maintenance" class="striped-list">
{% for item in upcoming_maintenance %}
<li>
<span class="list-title"
>{{ item.equipment_name }} · {{ item.scenario_name }}</span
>
<span class="list-detail"
>{{ item.date_display }} · {{ item.cost_display }} · {{ item.description
}}</span
>
</li>
{% endfor %}
</ul>
<p
id="upcoming-maintenance-empty"
class="empty-state"
{%
if
upcoming_maintenance|length
>
0 %} hidden{% endif %}> Schedule maintenance activities to track upcoming
events.
</p>
</section>
{% endblock %} {% block scripts %} {{ super() }}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script id="dashboard-data" type="application/json">
{{ {
"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": report_available
} | tojson }}
</script>
<script>
const SUMMARY_FIELDS = [
{ key: "mean", label: "Mean" },
{ key: "median", label: "Median" },
{ key: "min", label: "Min" },
{ key: "max", label: "Max" },
{ key: "std_dev", label: "Std Dev" },
{ key: "variance", label: "Variance" },
{ key: "percentile_5", label: "5th Percentile" },
{ key: "percentile_10", label: "10th Percentile" },
{ key: "percentile_90", label: "90th Percentile" },
{ key: "percentile_95", label: "95th Percentile" },
{ key: "value_at_risk_95", label: "VaR (95%)" },
{
key: "expected_shortfall_95",
label: "Expected Shortfall (95%)",
},
];
async function fetchSummary(results) {
const response = await fetch("/api/reporting/summary", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(results),
});
if (!response.ok) {
const message = await response.json();
throw new Error(message.detail || "Failed to retrieve summary");
(() => {
const dataElement = document.getElementById("dashboard-data");
if (!dataElement) {
return;
}
return response.json();
}
function getResultsFromInput() {
const textarea = document.getElementById("results-input");
let state = {};
try {
const parsed = JSON.parse(textarea.value || "[]");
if (!Array.isArray(parsed)) {
throw new Error("Input must be a JSON array");
}
return parsed;
state = JSON.parse(dataElement.textContent || "{}");
} catch (error) {
throw new Error(`Invalid JSON input: ${error.message}`);
}
console.error("Failed to parse dashboard data", error);
return;
}
function renderSummary(summary) {
const grid = document.getElementById("summary-grid");
grid.innerHTML = "";
SUMMARY_FIELDS.forEach(({ key, label }) => {
const rawValue = summary[key];
const numericValue = Number(rawValue);
const display = Number.isFinite(numericValue)
? numericValue.toFixed(2)
: "—";
const metric = document.createElement("div");
metric.className = "metric";
metric.innerHTML = `
<div class="metric-label">${label}</div>
<div class="metric-value">${display}</div>
`;
grid.appendChild(metric);
});
}
let chartInstance = null;
function renderChart(summary) {
const ctx = document.getElementById("summary-chart").getContext("2d");
const percentilePoints = [
{ label: "Min", value: summary.min },
{ label: "P5", value: summary.percentile_5 },
{ label: "P10", value: summary.percentile_10 },
{ label: "Median", value: summary.median },
{ label: "Mean", value: summary.mean },
{ label: "P90", value: summary.percentile_90 },
{ label: "P95", value: summary.percentile_95 },
{ label: "Max", value: summary.max },
];
const labels = percentilePoints.map((point) => point.label);
const dataPoints = percentilePoints.map((point) =>
Number(point.value ?? 0)
const statusElement = document.getElementById("dashboard-status");
const summaryContainer = document.getElementById("summary-metrics");
const summaryEmpty = document.getElementById("summary-empty");
const scenarioTableBody = document.querySelector("#scenario-table tbody");
const scenarioEmpty = document.getElementById("scenario-table-empty");
const overallMetricsList = document.getElementById("overall-metrics");
const overallMetricsEmpty = document.getElementById(
"overall-metrics-empty"
);
const recentList = document.getElementById("recent-simulations");
const recentEmpty = document.getElementById("recent-simulations-empty");
const maintenanceList = document.getElementById("upcoming-maintenance");
const maintenanceEmpty = document.getElementById(
"upcoming-maintenance-empty"
);
const refreshButton = document.getElementById("refresh-dashboard");
const costChartCanvas = document.getElementById("cost-chart");
const costChartEmpty = document.getElementById("cost-chart-empty");
const activityChartCanvas = document.getElementById("activity-chart");
const activityChartEmpty = document.getElementById("activity-chart-empty");
const tailRiskLines = [
{ label: "VaR (95%)", value: summary.value_at_risk_95 },
{ label: "ES (95%)", value: summary.expected_shortfall_95 },
]
.map(({ label, value }) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return null;
let costChartInstance = null;
let activityChartInstance = null;
function setStatus(message, variant = "success") {
if (!statusElement) {
return;
}
return `${label}: ${numeric.toFixed(2)}`;
})
.filter((line) => line !== null);
if (chartInstance) {
chartInstance.destroy();
if (!message) {
statusElement.hidden = true;
statusElement.textContent = "";
statusElement.classList.remove("success", "error");
return;
}
statusElement.textContent = message;
statusElement.hidden = false;
statusElement.classList.toggle("success", variant === "success");
statusElement.classList.toggle("error", variant !== "success");
}
chartInstance = new Chart(ctx, {
type: "line",
function renderSummaryMetrics() {
if (!summaryContainer || !summaryEmpty) {
return;
}
summaryContainer.innerHTML = "";
const metrics = Array.isArray(state.summary_metrics)
? state.summary_metrics
: [];
metrics.forEach((metric) => {
const card = document.createElement("article");
card.className = "metric-card";
card.innerHTML = `
<span class="metric-label">${metric.label}</span>
<span class="metric-value">${metric.value}</span>
`;
summaryContainer.appendChild(card);
});
summaryEmpty.hidden = metrics.length > 0;
}
function renderScenarioTable() {
if (!scenarioTableBody || !scenarioEmpty) {
return;
}
scenarioTableBody.innerHTML = "";
const rows = Array.isArray(state.scenario_rows)
? state.scenario_rows
: [];
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${row.scenario_name}</td>
<td>${row.parameter_display}</td>
<td>${row.equipment_display}</td>
<td>${row.capex_display}</td>
<td>${row.opex_display}</td>
<td>${row.production_display}</td>
<td>${row.consumption_display}</td>
<td>${row.maintenance_display}</td>
<td>${row.iterations_display}</td>
<td>${row.simulation_mean_display}</td>
`;
scenarioTableBody.appendChild(tr);
});
scenarioEmpty.hidden = rows.length > 0;
}
function renderOverallMetrics() {
if (!overallMetricsList || !overallMetricsEmpty) {
return;
}
overallMetricsList.innerHTML = "";
const items = Array.isArray(state.overall_report_metrics)
? state.overall_report_metrics
: [];
items.forEach((metric) => {
const li = document.createElement("li");
li.innerHTML = `
<span class="metric-label">${metric.label}</span>
<span class="metric-value">${metric.value}</span>
`;
overallMetricsList.appendChild(li);
});
overallMetricsEmpty.hidden =
Boolean(state.report_available) && items.length > 0;
if (!Boolean(state.report_available)) {
overallMetricsEmpty.hidden = false;
}
}
function renderRecentSimulations() {
if (!recentList || !recentEmpty) {
return;
}
recentList.innerHTML = "";
const runs = Array.isArray(state.recent_simulations)
? state.recent_simulations
: [];
runs.forEach((run) => {
const li = document.createElement("li");
li.innerHTML = `
<span class="list-title">${run.scenario_name}</span>
<span class="list-detail">Iterations: ${run.iterations_display} · Mean: ${run.mean_display} · P95: ${run.p95_display}</span>
`;
recentList.appendChild(li);
});
recentEmpty.hidden = runs.length > 0;
}
function renderMaintenance() {
if (!maintenanceList || !maintenanceEmpty) {
return;
}
maintenanceList.innerHTML = "";
const items = Array.isArray(state.upcoming_maintenance)
? state.upcoming_maintenance
: [];
items.forEach((item) => {
const li = document.createElement("li");
li.innerHTML = `
<span class="list-title">${item.equipment_name} · ${item.scenario_name}</span>
<span class="list-detail">${item.date_display} · ${item.cost_display} · ${item.description}</span>
`;
maintenanceList.appendChild(li);
});
maintenanceEmpty.hidden = items.length > 0;
}
function renderCostChart() {
if (!costChartCanvas || !costChartEmpty) {
return;
}
if (costChartInstance) {
costChartInstance.destroy();
costChartInstance = null;
}
const hasData =
Boolean(state.cost_chart_has_data) &&
Array.isArray(state.scenario_cost_chart?.labels) &&
state.scenario_cost_chart.labels.length > 0;
costChartCanvas.hidden = !hasData;
costChartEmpty.hidden = hasData;
if (!hasData) {
return;
}
costChartInstance = new Chart(costChartCanvas.getContext("2d"), {
type: "bar",
data: {
labels,
labels: state.scenario_cost_chart.labels,
datasets: [
{
label: "Result Summary",
data: dataPoints,
borderColor: "#2563eb",
backgroundColor: "rgba(37, 99, 235, 0.2)",
tension: 0.3,
label: "CAPEX",
data: state.scenario_cost_chart.capex,
backgroundColor: "#1d4ed8",
},
{
label: "OPEX",
data: state.scenario_cost_chart.opex,
backgroundColor: "#38bdf8",
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: false,
},
y: {
beginAtZero: true,
},
},
plugins: {
legend: {
position: "bottom",
},
},
},
});
}
function renderActivityChart() {
if (!activityChartCanvas || !activityChartEmpty) {
return;
}
if (activityChartInstance) {
activityChartInstance.destroy();
activityChartInstance = null;
}
const hasData =
Boolean(state.activity_chart_has_data) &&
Array.isArray(state.scenario_activity_chart?.labels) &&
state.scenario_activity_chart.labels.length > 0;
activityChartCanvas.hidden = !hasData;
activityChartEmpty.hidden = hasData;
if (!hasData) {
return;
}
activityChartInstance = new Chart(activityChartCanvas.getContext("2d"), {
type: "line",
data: {
labels: state.scenario_activity_chart.labels,
datasets: [
{
label: "Production",
data: state.scenario_activity_chart.production,
borderColor: "#22c55e",
backgroundColor: "rgba(34, 197, 94, 0.18)",
tension: 0.35,
fill: true,
},
{
label: "Consumption",
data: state.scenario_activity_chart.consumption,
borderColor: "#ef4444",
backgroundColor: "rgba(239, 68, 68, 0.18)",
tension: 0.35,
fill: true,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
afterBody: () => tailRiskLines,
},
legend: {
position: "bottom",
},
},
scales: {
@@ -201,61 +523,54 @@ block head_extra %} {{ super() }}
});
}
function showError(message) {
const errorElement = document.getElementById("error-message");
errorElement.textContent = message;
errorElement.hidden = false;
function renderDashboard() {
renderSummaryMetrics();
renderScenarioTable();
renderOverallMetrics();
renderRecentSimulations();
renderMaintenance();
renderCostChart();
renderActivityChart();
}
function attachHandlers() {
const loadSampleButton = document.getElementById("load-sample");
const refreshButton = document.getElementById("refresh-dashboard");
function setLoading(isLoading) {
if (!refreshButton) {
return;
}
refreshButton.disabled = isLoading;
refreshButton.classList.toggle("is-loading", isLoading);
refreshButton.textContent = isLoading
? "Refreshing…"
: "Refresh Dashboard";
}
const sampleData = JSON.stringify(
[
{ result: 18.2 },
{ result: 22.1 },
{ result: 30.4 },
{ result: 25.7 },
{ result: 28.3 },
],
null,
2
);
loadSampleButton.addEventListener("click", () => {
document.getElementById("results-input").value = sampleData;
});
refreshButton.addEventListener("click", async () => {
async function refreshDashboard() {
try {
const results = getResultsFromInput();
const summary = await fetchSummary(results);
renderSummary(summary);
renderChart(summary);
document.getElementById("error-message").hidden = true;
setLoading(true);
setStatus("");
const response = await fetch("/ui/dashboard/data", {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error("Failed to refresh dashboard data.");
}
const payload = await response.json();
state = payload;
renderDashboard();
setStatus("Dashboard updated.", "success");
} catch (error) {
console.error(error);
showError(error.message);
}
});
document.getElementById("results-input").value = sampleData;
}
async function initializeDashboard() {
try {
attachHandlers();
const initialResults = getResultsFromInput();
const summary = await fetchSummary(initialResults);
renderSummary(summary);
renderChart(summary);
} catch (error) {
console.error(error);
showError(error.message);
setStatus(error.message || "Unable to refresh dashboard.", "error");
} finally {
setLoading(false);
}
}
initializeDashboard();
if (refreshButton) {
refreshButton.addEventListener("click", refreshDashboard);
}
renderDashboard();
})();
</script>
{% endblock %}

View File

@@ -8,10 +8,17 @@
{% block head_extra %}{% endblock %}
</head>
<body>
<div class="app-layout">
<aside class="app-sidebar" aria-label="Primary navigation">
{% include "partials/base_header.html" %}
<main id="content" class="container">
</aside>
<div class="app-main">
<main id="content" class="app-content container">
{% block content %}{% endblock %}
</main>
{% include "partials/base_footer.html" %} {% block scripts %}{% endblock %}
{% include "partials/base_footer.html" %}
</div>
</div>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,8 +1,202 @@
{% extends "base.html" %}
{% block title %}Consumption · CalMiner{% endblock %}
{% extends "base.html" %} {% block title %}Consumption · CalMiner{% endblock %}
{% block content %}
<section class="panel">
<h2>Consumption Tracking</h2>
<p>Placeholder for tracking scenario consumption metrics. Integration with APIs coming soon.</p>
<div class="form-grid">
<label for="consumption-scenario-filter">
Scenario filter
<select id="consumption-scenario-filter">
<option value="">Select a scenario</option>
{% for scenario in scenarios %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
{% endfor %}
</select>
</label>
</div>
<div id="consumption-empty" class="empty-state">
Choose a scenario to review its consumption records.
</div>
<div id="consumption-table-wrapper" class="table-container hidden">
<table aria-label="Scenario consumption records">
<thead>
<tr>
<th scope="col">Amount</th>
<th scope="col">Description</th>
</tr>
</thead>
<tbody id="consumption-table-body"></tbody>
</table>
</div>
</section>
<section class="panel">
<h2>Add Consumption Record</h2>
{% if scenarios %}
<form id="consumption-form" class="form-grid">
<label for="consumption-form-scenario">
Scenario
<select id="consumption-form-scenario" name="scenario_id" required>
<option value="" disabled selected>Select a scenario</option>
{% for scenario in scenarios %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
{% endfor %}
</select>
</label>
<label for="consumption-form-amount">
Amount
<input
id="consumption-form-amount"
type="number"
name="amount"
min="0"
step="0.01"
required
/>
</label>
<label for="consumption-form-description">
Description (optional)
<textarea
id="consumption-form-description"
name="description"
rows="3"
></textarea>
</label>
<button type="submit" class="btn primary">Add Record</button>
</form>
<p id="consumption-feedback" class="feedback hidden" role="status"></p>
{% else %}
<p class="empty-state">
Create a scenario before adding consumption records.
</p>
{% endif %}
</section>
<script>
const scenarios = {{ scenarios | tojson | safe }};
const consumptionByScenario = {{ consumption_by_scenario | tojson | safe }};
const filterSelect = document.getElementById("consumption-scenario-filter");
const tableWrapper = document.getElementById("consumption-table-wrapper");
const tableBody = document.getElementById("consumption-table-body");
const emptyState = document.getElementById("consumption-empty");
const form = document.getElementById("consumption-form");
const feedbackEl = document.getElementById("consumption-feedback");
function showFeedback(message, type = "success") {
if (!feedbackEl) {
return;
}
feedbackEl.textContent = message;
feedbackEl.classList.remove("hidden", "success", "error");
feedbackEl.classList.add(type);
}
function hideFeedback() {
if (!feedbackEl) {
return;
}
feedbackEl.classList.add("hidden");
feedbackEl.textContent = "";
}
function formatAmount(value) {
return Number(value).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function renderConsumptionRows(scenarioId) {
const key = String(scenarioId);
const records = consumptionByScenario[key] || [];
tableBody.innerHTML = "";
if (!records.length) {
emptyState.textContent = "No consumption records for this scenario yet.";
emptyState.classList.remove("hidden");
tableWrapper.classList.add("hidden");
return;
}
emptyState.classList.add("hidden");
tableWrapper.classList.remove("hidden");
records.forEach((record) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${formatAmount(record.amount)}</td>
<td>${record.description || "—"}</td>
`;
tableBody.appendChild(row);
});
}
if (filterSelect) {
filterSelect.addEventListener("change", (event) => {
const value = event.target.value;
if (!value) {
emptyState.textContent = "Choose a scenario to review its consumption records.";
emptyState.classList.remove("hidden");
tableWrapper.classList.add("hidden");
tableBody.innerHTML = "";
return;
}
renderConsumptionRows(value);
});
}
async function submitConsumption(event) {
event.preventDefault();
hideFeedback();
const formData = new FormData(form);
const scenarioId = formData.get("scenario_id");
const payload = {
scenario_id: scenarioId ? Number(scenarioId) : null,
amount: Number(formData.get("amount")),
description: formData.get("description") || null,
};
try {
const response = await fetch("/api/consumption/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorDetail = await response.json().catch(() => ({}));
throw new Error(errorDetail.detail || "Unable to add consumption record.");
}
const result = await response.json();
const mapKey = String(result.scenario_id);
if (!Array.isArray(consumptionByScenario[mapKey])) {
consumptionByScenario[mapKey] = [];
}
consumptionByScenario[mapKey].push(result);
form.reset();
showFeedback("Consumption record saved.", "success");
if (filterSelect && filterSelect.value === String(result.scenario_id)) {
renderConsumptionRows(filterSelect.value);
}
} catch (error) {
showFeedback(error.message || "An unexpected error occurred.", "error");
}
}
if (form) {
form.addEventListener("submit", submitConsumption);
}
if (filterSelect && filterSelect.value) {
renderConsumptionRows(filterSelect.value);
}
</script>
{% endblock %}

View File

@@ -1,8 +1,348 @@
{% extends "base.html" %}
{% block title %}Costs · CalMiner{% endblock %}
{% block content %}
{% extends "base.html" %} {% block title %}Costs · CalMiner{% endblock %} {%
block content %}
<section class="panel">
<h2>Costs</h2>
<p>This view will surface CAPEX and OPEX entries tied to scenarios. API wiring pending.</p>
<h2>Cost Overview</h2>
{% if scenarios %}
<div class="form-grid">
<label for="costs-scenario-filter">
Scenario filter
<select id="costs-scenario-filter">
<option value="">Select a scenario</option>
{% for scenario in scenarios %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
{% endfor %}
</select>
</label>
</div>
{% else %}
<p class="empty-state">Create a scenario to review cost information.</p>
{% endif %}
<div id="costs-empty" class="empty-state">
Choose a scenario to review CAPEX and OPEX details.
</div>
<div id="costs-data" class="hidden">
<div class="table-container">
<h3>Capital Expenditures (CAPEX)</h3>
<table aria-label="Scenario CAPEX records">
<thead>
<tr>
<th scope="col">Amount</th>
<th scope="col">Description</th>
</tr>
</thead>
<tbody id="capex-table-body"></tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<th id="capex-total"></th>
</tr>
</tfoot>
</table>
<p id="capex-empty" class="empty-state hidden">
No CAPEX records for this scenario yet.
</p>
</div>
<div class="table-container">
<h3>Operational Expenditures (OPEX)</h3>
<table aria-label="Scenario OPEX records">
<thead>
<tr>
<th scope="col">Amount</th>
<th scope="col">Description</th>
</tr>
</thead>
<tbody id="opex-table-body"></tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<th id="opex-total"></th>
</tr>
</tfoot>
</table>
<p id="opex-empty" class="empty-state hidden">
No OPEX records for this scenario yet.
</p>
</div>
</div>
</section>
<section class="panel">
<h2>Add CAPEX Entry</h2>
{% if scenarios %}
<form id="capex-form" class="form-grid">
<label for="capex-form-scenario">
Scenario
<select id="capex-form-scenario" name="scenario_id" required>
<option value="" disabled selected>Select a scenario</option>
{% for scenario in scenarios %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
{% endfor %}
</select>
</label>
<label for="capex-form-amount">
Amount
<input
id="capex-form-amount"
type="number"
name="amount"
min="0"
step="0.01"
required
/>
</label>
<label for="capex-form-description">
Description (optional)
<textarea
id="capex-form-description"
name="description"
rows="3"
></textarea>
</label>
<button type="submit" class="btn primary">Add CAPEX</button>
</form>
<p id="capex-feedback" class="feedback hidden" role="status"></p>
{% else %}
<p class="empty-state">Create a scenario before adding CAPEX entries.</p>
{% endif %}
</section>
<section class="panel">
<h2>Add OPEX Entry</h2>
{% if scenarios %}
<form id="opex-form" class="form-grid">
<label for="opex-form-scenario">
Scenario
<select id="opex-form-scenario" name="scenario_id" required>
<option value="" disabled selected>Select a scenario</option>
{% for scenario in scenarios %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
{% endfor %}
</select>
</label>
<label for="opex-form-amount">
Amount
<input
id="opex-form-amount"
type="number"
name="amount"
min="0"
step="0.01"
required
/>
</label>
<label for="opex-form-description">
Description (optional)
<textarea
id="opex-form-description"
name="description"
rows="3"
></textarea>
</label>
<button type="submit" class="btn primary">Add OPEX</button>
</form>
<p id="opex-feedback" class="feedback hidden" role="status"></p>
{% else %}
<p class="empty-state">Create a scenario before adding OPEX entries.</p>
{% endif %}
</section>
<script>
const scenarios = {{ scenarios | tojson | safe }};
const capexByScenario = {{ capex_by_scenario | tojson | safe }};
const opexByScenario = {{ opex_by_scenario | tojson | safe }};
const filterSelect = document.getElementById("costs-scenario-filter");
const costsEmptyState = document.getElementById("costs-empty");
const costsDataWrapper = document.getElementById("costs-data");
const capexTableBody = document.getElementById("capex-table-body");
const opexTableBody = document.getElementById("opex-table-body");
const capexEmpty = document.getElementById("capex-empty");
const opexEmpty = document.getElementById("opex-empty");
const capexTotal = document.getElementById("capex-total");
const opexTotal = document.getElementById("opex-total");
const capexForm = document.getElementById("capex-form");
const opexForm = document.getElementById("opex-form");
const capexFeedback = document.getElementById("capex-feedback");
const opexFeedback = document.getElementById("opex-feedback");
const capexFormScenario = document.getElementById("capex-form-scenario");
const opexFormScenario = document.getElementById("opex-form-scenario");
function showFeedback(element, message, type = "success") {
if (!element) {
return;
}
element.textContent = message;
element.classList.remove("hidden", "success", "error");
element.classList.add(type);
}
function hideFeedback(element) {
if (!element) {
return;
}
element.classList.add("hidden");
element.textContent = "";
}
function formatAmount(value) {
return Number(value).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function sumAmount(records) {
return records.reduce((total, record) => total + Number(record.amount || 0), 0);
}
function renderCostTables(scenarioId) {
const capexRecords = capexByScenario[String(scenarioId)] || [];
const opexRecords = opexByScenario[String(scenarioId)] || [];
capexTableBody.innerHTML = "";
opexTableBody.innerHTML = "";
if (!capexRecords.length) {
capexEmpty.classList.remove("hidden");
} else {
capexEmpty.classList.add("hidden");
capexRecords.forEach((record) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${formatAmount(record.amount)}</td>
<td>${record.description || "—"}</td>
`;
capexTableBody.appendChild(row);
});
}
if (!opexRecords.length) {
opexEmpty.classList.remove("hidden");
} else {
opexEmpty.classList.add("hidden");
opexRecords.forEach((record) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${formatAmount(record.amount)}</td>
<td>${record.description || "—"}</td>
`;
opexTableBody.appendChild(row);
});
}
capexTotal.textContent = formatAmount(sumAmount(capexRecords));
opexTotal.textContent = formatAmount(sumAmount(opexRecords));
}
function toggleCostView(show) {
if (show) {
costsEmptyState.classList.add("hidden");
costsDataWrapper.classList.remove("hidden");
} else {
costsEmptyState.classList.remove("hidden");
costsDataWrapper.classList.add("hidden");
capexTableBody.innerHTML = "";
opexTableBody.innerHTML = "";
capexTotal.textContent = "—";
opexTotal.textContent = "—";
capexEmpty.classList.add("hidden");
opexEmpty.classList.add("hidden");
}
}
function syncFormSelections(value) {
if (capexFormScenario) {
capexFormScenario.value = value || "";
}
if (opexFormScenario) {
opexFormScenario.value = value || "";
}
}
if (filterSelect) {
filterSelect.addEventListener("change", (event) => {
const value = event.target.value;
if (!value) {
toggleCostView(false);
syncFormSelections("");
return;
}
toggleCostView(true);
renderCostTables(value);
syncFormSelections(value);
});
}
async function submitCostEntry(event, targetUrl, storageMap, feedbackEl) {
event.preventDefault();
hideFeedback(feedbackEl);
const formData = new FormData(event.target);
const scenarioId = formData.get("scenario_id");
const payload = {
scenario_id: scenarioId ? Number(scenarioId) : null,
amount: Number(formData.get("amount")),
description: formData.get("description") || null,
};
if (!payload.scenario_id) {
showFeedback(feedbackEl, "Select a scenario before submitting.", "error");
return;
}
try {
const response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorDetail = await response.json().catch(() => ({}));
throw new Error(errorDetail.detail || "Unable to save cost entry.");
}
const result = await response.json();
const mapKey = String(result.scenario_id);
if (!Array.isArray(storageMap[mapKey])) {
storageMap[mapKey] = [];
}
storageMap[mapKey].push(result);
event.target.reset();
showFeedback(feedbackEl, "Entry saved successfully.", "success");
if (filterSelect && filterSelect.value === mapKey) {
renderCostTables(mapKey);
}
} catch (error) {
showFeedback(feedbackEl, error.message || "An unexpected error occurred.", "error");
}
}
if (capexForm) {
capexForm.addEventListener("submit", (event) =>
submitCostEntry(event, "/api/costs/capex", capexByScenario, capexFeedback)
);
}
if (opexForm) {
opexForm.addEventListener("submit", (event) =>
submitCostEntry(event, "/api/costs/opex", opexByScenario, opexFeedback)
);
}
if (filterSelect && filterSelect.value) {
toggleCostView(true);
renderCostTables(filterSelect.value);
syncFormSelections(filterSelect.value);
}
</script>
{% endblock %}

View File

@@ -1,8 +1,190 @@
{% extends "base.html" %}
{% block title %}Equipment · CalMiner{% endblock %}
{% block content %}
{% extends "base.html" %} {% block title %}Equipment · CalMiner{% endblock %} {%
block content %}
<section class="panel">
<h2>Equipment Inventory</h2>
<p>Placeholder for equipment CRUD interface. To be wired to `/api/equipment/` routes.</p>
{% if scenarios %}
<div class="form-grid">
<label for="equipment-scenario-filter">
Scenario filter
<select id="equipment-scenario-filter">
<option value="">Select a scenario</option>
{% for scenario in scenarios %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
{% endfor %}
</select>
</label>
</div>
{% else %}
<p class="empty-state">Create a scenario to view equipment inventory.</p>
{% endif %}
<div id="equipment-empty" class="empty-state">
Choose a scenario to review the equipment list.
</div>
<div id="equipment-table-wrapper" class="table-container hidden">
<table aria-label="Scenario equipment inventory">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Description</th>
</tr>
</thead>
<tbody id="equipment-table-body"></tbody>
</table>
</div>
</section>
<section class="panel">
<h2>Add Equipment</h2>
{% if scenarios %}
<form id="equipment-form" class="form-grid">
<label for="equipment-form-scenario">
Scenario
<select id="equipment-form-scenario" name="scenario_id" required>
<option value="" disabled selected>Select a scenario</option>
{% for scenario in scenarios %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
{% endfor %}
</select>
</label>
<label for="equipment-form-name">
Equipment name
<input id="equipment-form-name" type="text" name="name" required />
</label>
<label for="equipment-form-description">
Description (optional)
<textarea
id="equipment-form-description"
name="description"
rows="3"
></textarea>
</label>
<button type="submit" class="btn primary">Add Equipment</button>
</form>
<p id="equipment-feedback" class="feedback hidden" role="status"></p>
{% else %}
<p class="empty-state">Create a scenario before managing equipment.</p>
{% endif %}
</section>
<script>
const scenarios = {{ scenarios | tojson | safe }};
const equipmentByScenario = {{ equipment_by_scenario | tojson | safe }};
const filterSelect = document.getElementById("equipment-scenario-filter");
const tableWrapper = document.getElementById("equipment-table-wrapper");
const tableBody = document.getElementById("equipment-table-body");
const emptyState = document.getElementById("equipment-empty");
const form = document.getElementById("equipment-form");
const feedbackEl = document.getElementById("equipment-feedback");
function showFeedback(message, type = "success") {
if (!feedbackEl) {
return;
}
feedbackEl.textContent = message;
feedbackEl.classList.remove("hidden", "success", "error");
feedbackEl.classList.add(type);
}
function hideFeedback() {
if (!feedbackEl) {
return;
}
feedbackEl.classList.add("hidden");
feedbackEl.textContent = "";
}
function renderEquipmentRows(scenarioId) {
const key = String(scenarioId);
const records = equipmentByScenario[key] || [];
tableBody.innerHTML = "";
if (!records.length) {
emptyState.textContent = "No equipment recorded for this scenario yet.";
emptyState.classList.remove("hidden");
tableWrapper.classList.add("hidden");
return;
}
emptyState.classList.add("hidden");
tableWrapper.classList.remove("hidden");
records.forEach((record) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${record.name || "—"}</td>
<td>${record.description || "—"}</td>
`;
tableBody.appendChild(row);
});
}
if (filterSelect) {
filterSelect.addEventListener("change", (event) => {
const value = event.target.value;
if (!value) {
emptyState.textContent = "Choose a scenario to review the equipment list.";
emptyState.classList.remove("hidden");
tableWrapper.classList.add("hidden");
tableBody.innerHTML = "";
return;
}
renderEquipmentRows(value);
});
}
async function submitEquipment(event) {
event.preventDefault();
hideFeedback();
const formData = new FormData(form);
const scenarioId = formData.get("scenario_id");
const payload = {
scenario_id: scenarioId ? Number(scenarioId) : null,
name: formData.get("name"),
description: formData.get("description") || null,
};
try {
const response = await fetch("/api/equipment/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorDetail = await response.json().catch(() => ({}));
throw new Error(errorDetail.detail || "Unable to add equipment record.");
}
const result = await response.json();
const mapKey = String(result.scenario_id);
if (!Array.isArray(equipmentByScenario[mapKey])) {
equipmentByScenario[mapKey] = [];
}
equipmentByScenario[mapKey].push(result);
form.reset();
showFeedback("Equipment saved.", "success");
if (filterSelect && filterSelect.value === String(result.scenario_id)) {
renderEquipmentRows(filterSelect.value);
}
} catch (error) {
showFeedback(error.message || "An unexpected error occurred.", "error");
}
}
if (form) {
form.addEventListener("submit", submitEquipment);
}
if (filterSelect && filterSelect.value) {
renderEquipmentRows(filterSelect.value);
}
</script>
{% endblock %}

View File

@@ -1,8 +1,308 @@
{% extends "base.html" %}
{% block title %}Maintenance · CalMiner{% endblock %}
{% extends "base.html" %} {% block title %}Maintenance · CalMiner{% endblock %}
{% block content %}
<section class="panel">
<h2>Maintenance Log</h2>
<p>Placeholder for maintenance records management. Future work will surface CRUD flows tied to `/api/maintenance/`.</p>
<h2>Maintenance Schedule</h2>
{% if scenarios %}
<div class="form-grid">
<label for="maintenance-scenario-filter">
Scenario filter
<select id="maintenance-scenario-filter">
<option value="">Select a scenario</option>
{% for scenario in scenarios %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
{% endfor %}
</select>
</label>
</div>
{% else %}
<p class="empty-state">Create a scenario to view maintenance entries.</p>
{% endif %}
<div id="maintenance-empty" class="empty-state">
Choose a scenario to review upcoming or completed maintenance.
</div>
<div id="maintenance-table-wrapper" class="table-container hidden">
<table aria-label="Scenario maintenance records">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Equipment</th>
<th scope="col">Cost</th>
<th scope="col">Description</th>
</tr>
</thead>
<tbody id="maintenance-table-body"></tbody>
</table>
</div>
</section>
<section class="panel">
<h2>Add Maintenance Entry</h2>
{% if scenarios %}
<form id="maintenance-form" class="form-grid">
<label for="maintenance-form-scenario">
Scenario
<select id="maintenance-form-scenario" name="scenario_id" required>
<option value="" disabled selected>Select a scenario</option>
{% for scenario in scenarios %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
{% endfor %}
</select>
</label>
<label for="maintenance-form-equipment">
Equipment
<select
id="maintenance-form-equipment"
name="equipment_id"
required
disabled
>
<option value="" disabled selected>Select equipment</option>
</select>
</label>
<p id="maintenance-equipment-empty" class="empty-state hidden">
Add equipment for this scenario before scheduling maintenance.
</p>
<label for="maintenance-form-date">
Date
<input
id="maintenance-form-date"
type="date"
name="maintenance_date"
required
/>
</label>
<label for="maintenance-form-cost">
Cost
<input
id="maintenance-form-cost"
type="number"
name="cost"
min="0"
step="0.01"
required
/>
</label>
<label for="maintenance-form-description">
Description (optional)
<textarea
id="maintenance-form-description"
name="description"
rows="3"
></textarea>
</label>
<button type="submit" class="btn primary">Add Maintenance</button>
</form>
<p id="maintenance-feedback" class="feedback hidden" role="status"></p>
{% else %}
<p class="empty-state">
Create a scenario before managing maintenance entries.
</p>
{% endif %}
</section>
<script>
const scenarios = {{ scenarios | tojson | safe }};
const equipmentByScenario = {{ equipment_by_scenario | tojson | safe }};
const maintenanceByScenario = {{ maintenance_by_scenario | tojson | safe }};
const filterSelect = document.getElementById("maintenance-scenario-filter");
const tableWrapper = document.getElementById("maintenance-table-wrapper");
const tableBody = document.getElementById("maintenance-table-body");
const emptyState = document.getElementById("maintenance-empty");
const form = document.getElementById("maintenance-form");
const feedbackEl = document.getElementById("maintenance-feedback");
const formScenarioSelect = document.getElementById("maintenance-form-scenario");
const equipmentSelect = document.getElementById("maintenance-form-equipment");
const equipmentEmptyState = document.getElementById("maintenance-equipment-empty");
function showFeedback(message, type = "success") {
if (!feedbackEl) {
return;
}
feedbackEl.textContent = message;
feedbackEl.classList.remove("hidden", "success", "error");
feedbackEl.classList.add(type);
}
function hideFeedback() {
if (!feedbackEl) {
return;
}
feedbackEl.classList.add("hidden");
feedbackEl.textContent = "";
}
function formatCost(value) {
return Number(value).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function formatDate(value) {
if (!value) {
return "—";
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleDateString();
}
function renderMaintenanceRows(scenarioId) {
const key = String(scenarioId);
const records = maintenanceByScenario[key] || [];
tableBody.innerHTML = "";
if (!records.length) {
emptyState.textContent = "No maintenance entries recorded for this scenario yet.";
emptyState.classList.remove("hidden");
tableWrapper.classList.add("hidden");
return;
}
emptyState.classList.add("hidden");
tableWrapper.classList.remove("hidden");
records.forEach((record) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${formatDate(record.maintenance_date)}</td>
<td>${record.equipment_name || "—"}</td>
<td>${formatCost(record.cost)}</td>
<td>${record.description || "—"}</td>
`;
tableBody.appendChild(row);
});
}
function populateEquipmentOptions(scenarioId) {
if (!equipmentSelect) {
return;
}
equipmentSelect.innerHTML = '<option value="" disabled selected>Select equipment</option>';
equipmentSelect.disabled = true;
if (equipmentEmptyState) {
equipmentEmptyState.classList.add("hidden");
}
if (!scenarioId) {
return;
}
const list = equipmentByScenario[String(scenarioId)] || [];
if (!list.length) {
if (equipmentEmptyState) {
equipmentEmptyState.textContent = "Add equipment for this scenario before scheduling maintenance.";
equipmentEmptyState.classList.remove("hidden");
}
return;
}
list.forEach((item) => {
const option = document.createElement("option");
option.value = item.id;
option.textContent = item.name || `Equipment ${item.id}`;
equipmentSelect.appendChild(option);
});
equipmentSelect.disabled = false;
}
if (filterSelect) {
filterSelect.addEventListener("change", (event) => {
const value = event.target.value;
if (!value) {
emptyState.textContent = "Choose a scenario to review upcoming or completed maintenance.";
emptyState.classList.remove("hidden");
tableWrapper.classList.add("hidden");
tableBody.innerHTML = "";
return;
}
renderMaintenanceRows(value);
});
}
if (formScenarioSelect) {
formScenarioSelect.addEventListener("change", (event) => {
const value = event.target.value;
populateEquipmentOptions(value);
});
}
async function submitMaintenance(event) {
event.preventDefault();
hideFeedback();
const formData = new FormData(form);
const scenarioId = formData.get("scenario_id");
const equipmentId = formData.get("equipment_id");
const payload = {
scenario_id: scenarioId ? Number(scenarioId) : null,
equipment_id: equipmentId ? Number(equipmentId) : null,
maintenance_date: formData.get("maintenance_date"),
cost: Number(formData.get("cost")),
description: formData.get("description") || null,
};
if (!payload.scenario_id || !payload.equipment_id) {
showFeedback("Select a scenario and equipment before submitting.", "error");
return;
}
try {
const response = await fetch("/api/maintenance/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorDetail = await response.json().catch(() => ({}));
throw new Error(errorDetail.detail || "Unable to add maintenance entry.");
}
const result = await response.json();
const mapKey = String(result.scenario_id);
if (!Array.isArray(maintenanceByScenario[mapKey])) {
maintenanceByScenario[mapKey] = [];
}
const equipmentList = equipmentByScenario[mapKey] || [];
const matchedEquipment = equipmentList.find(
(item) => Number(item.id) === Number(result.equipment_id)
);
result.equipment_name = matchedEquipment ? matchedEquipment.name : "";
maintenanceByScenario[mapKey].push(result);
form.reset();
populateEquipmentOptions(null);
showFeedback("Maintenance entry saved.", "success");
if (filterSelect && filterSelect.value === String(result.scenario_id)) {
renderMaintenanceRows(filterSelect.value);
}
} catch (error) {
showFeedback(error.message || "An unexpected error occurred.", "error");
}
}
if (form) {
form.addEventListener("submit", submitMaintenance);
}
if (filterSelect && filterSelect.value) {
renderMaintenanceRows(filterSelect.value);
}
if (formScenarioSelect && formScenarioSelect.value) {
populateEquipmentOptions(formScenarioSelect.value);
}
</script>
{% endblock %}

View File

@@ -1,17 +1,38 @@
<header class="site-header">
<div class="container header-inner">
<h1 class="site-title">CalMiner</h1>
<nav class="site-nav" aria-label="Primary navigation">
<a href="/ui/dashboard">Dashboard</a>
<a href="/ui/scenarios">Scenarios</a>
<a href="/ui/parameters">Parameters</a>
<a href="/ui/costs">Costs</a>
<a href="/ui/consumption">Consumption</a>
<a href="/ui/production">Production</a>
<a href="/ui/equipment">Equipment</a>
<a href="/ui/maintenance">Maintenance</a>
<a href="/ui/simulations">Simulations</a>
<a href="/ui/reporting">Reporting</a>
{% set nav_links = [
("/", "Dashboard"),
("/ui/scenarios", "Scenarios"),
("/ui/parameters", "Parameters"),
("/ui/costs", "Costs"),
("/ui/consumption", "Consumption"),
("/ui/production", "Production"),
("/ui/equipment", "Equipment"),
("/ui/maintenance", "Maintenance"),
("/ui/simulations", "Simulations"),
("/ui/reporting", "Reporting"),
] %}
<div class="sidebar-inner">
<div class="sidebar-brand">
<span class="brand-logo" aria-hidden="true">CM</span>
<div class="brand-text">
<span class="brand-title">CalMiner</span>
<span class="brand-subtitle">Mining Planner</span>
</div>
</div>
<nav class="sidebar-nav" aria-label="Primary navigation">
{% set current_path = request.url.path if request else "" %}
{% for href, label in nav_links %}
{% if href == "/" %}
{% set is_active = current_path == "/" %}
{% else %}
{% set is_active = current_path.startswith(href) %}
{% endif %}
<a
href="{{ href }}"
class="sidebar-link{% if is_active %} is-active{% endif %}"
>
{{ label }}
</a>
{% endfor %}
</nav>
</div>
</header>

View File

@@ -1,8 +1,204 @@
{% extends "base.html" %}
{% block title %}Production · CalMiner{% endblock %}
{% extends "base.html" %} {% block title %}Production · CalMiner{% endblock %}
{% block content %}
<section class="panel">
<h2>Production Output</h2>
<p>Placeholder for production metrics per scenario. Hook up to `/api/production/` endpoints.</p>
{% if scenarios %}
<div class="form-grid">
<label for="production-scenario-filter">
Scenario filter
<select id="production-scenario-filter">
<option value="">Select a scenario</option>
{% for scenario in scenarios %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
{% endfor %}
</select>
</label>
</div>
{% else %}
<p class="empty-state">Create a scenario to view production output data.</p>
{% endif %}
<div id="production-empty" class="empty-state">
Choose a scenario to review its production output.
</div>
<div id="production-table-wrapper" class="table-container hidden">
<table aria-label="Scenario production records">
<thead>
<tr>
<th scope="col">Amount</th>
<th scope="col">Description</th>
</tr>
</thead>
<tbody id="production-table-body"></tbody>
</table>
</div>
</section>
<section class="panel">
<h2>Add Production Output</h2>
{% if scenarios %}
<form id="production-form" class="form-grid">
<label for="production-form-scenario">
Scenario
<select id="production-form-scenario" name="scenario_id" required>
<option value="" disabled selected>Select a scenario</option>
{% for scenario in scenarios %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
{% endfor %}
</select>
</label>
<label for="production-form-amount">
Amount
<input
id="production-form-amount"
type="number"
name="amount"
min="0"
step="0.01"
required
/>
</label>
<label for="production-form-description">
Description (optional)
<textarea
id="production-form-description"
name="description"
rows="3"
></textarea>
</label>
<button type="submit" class="btn primary">Add Record</button>
</form>
<p id="production-feedback" class="feedback hidden" role="status"></p>
{% else %}
<p class="empty-state">Create a scenario before adding production output.</p>
{% endif %}
</section>
<script>
const scenarios = {{ scenarios | tojson | safe }};
const productionByScenario = {{ production_by_scenario | tojson | safe }};
const filterSelect = document.getElementById("production-scenario-filter");
const tableWrapper = document.getElementById("production-table-wrapper");
const tableBody = document.getElementById("production-table-body");
const emptyState = document.getElementById("production-empty");
const form = document.getElementById("production-form");
const feedbackEl = document.getElementById("production-feedback");
function showFeedback(message, type = "success") {
if (!feedbackEl) {
return;
}
feedbackEl.textContent = message;
feedbackEl.classList.remove("hidden", "success", "error");
feedbackEl.classList.add(type);
}
function hideFeedback() {
if (!feedbackEl) {
return;
}
feedbackEl.classList.add("hidden");
feedbackEl.textContent = "";
}
function formatAmount(value) {
return Number(value).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function renderProductionRows(scenarioId) {
const key = String(scenarioId);
const records = productionByScenario[key] || [];
tableBody.innerHTML = "";
if (!records.length) {
emptyState.textContent = "No production output recorded for this scenario yet.";
emptyState.classList.remove("hidden");
tableWrapper.classList.add("hidden");
return;
}
emptyState.classList.add("hidden");
tableWrapper.classList.remove("hidden");
records.forEach((record) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${formatAmount(record.amount)}</td>
<td>${record.description || "—"}</td>
`;
tableBody.appendChild(row);
});
}
if (filterSelect) {
filterSelect.addEventListener("change", (event) => {
const value = event.target.value;
if (!value) {
emptyState.textContent = "Choose a scenario to review its production output.";
emptyState.classList.remove("hidden");
tableWrapper.classList.add("hidden");
tableBody.innerHTML = "";
return;
}
renderProductionRows(value);
});
}
async function submitProduction(event) {
event.preventDefault();
hideFeedback();
const formData = new FormData(form);
const scenarioId = formData.get("scenario_id");
const payload = {
scenario_id: scenarioId ? Number(scenarioId) : null,
amount: Number(formData.get("amount")),
description: formData.get("description") || null,
};
try {
const response = await fetch("/api/production/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorDetail = await response.json().catch(() => ({}));
throw new Error(errorDetail.detail || "Unable to add production output record.");
}
const result = await response.json();
const mapKey = String(result.scenario_id);
if (!Array.isArray(productionByScenario[mapKey])) {
productionByScenario[mapKey] = [];
}
productionByScenario[mapKey].push(result);
form.reset();
showFeedback("Production output saved.", "success");
if (filterSelect && filterSelect.value === String(result.scenario_id)) {
renderProductionRows(filterSelect.value);
}
} catch (error) {
showFeedback(error.message || "An unexpected error occurred.", "error");
}
}
if (form) {
form.addEventListener("submit", submitProduction);
}
if (filterSelect && filterSelect.value) {
renderProductionRows(filterSelect.value);
}
</script>
{% endblock %}

View File

@@ -1,8 +1,170 @@
{% extends "base.html" %}
{% block title %}Reporting · CalMiner{% endblock %}
{% block content %}
{% extends "base.html" %} {% block title %}Reporting · CalMiner{% endblock %} {%
block content %}
<section class="panel">
<h2>Reporting</h2>
<p>Placeholder for aggregated KPI views connected to `/api/reporting/` endpoints.</p>
<h2>Scenario KPI Summary</h2>
<div class="button-row">
<button id="report-refresh" class="btn" type="button">
Refresh Metrics
</button>
</div>
<p id="report-feedback" class="feedback hidden" role="status"></p>
<div id="reporting-empty" class="empty-state hidden">
No reporting data available. Run a simulation to generate metrics.
</div>
<div id="reporting-table-wrapper" class="table-container hidden">
<table aria-label="Scenario reporting summary">
<thead>
<tr>
<th scope="col">Scenario</th>
<th scope="col">Iterations</th>
<th scope="col">Mean Result</th>
<th scope="col">Variance</th>
<th scope="col">Std. Dev</th>
<th scope="col">Percentile 5</th>
<th scope="col">Percentile 95</th>
<th scope="col">Value at Risk (95%)</th>
<th scope="col">Expected Shortfall (95%)</th>
</tr>
</thead>
<tbody id="reporting-table-body"></tbody>
</table>
</div>
</section>
<script>
const reportingSummaries = {{ report_summaries | tojson | safe }};
const REPORT_FIELDS = [
{ key: "iterations", label: "Iterations", decimals: 0 },
{ key: "mean", label: "Mean Result", decimals: 2 },
{ key: "variance", label: "Variance", decimals: 2 },
{ key: "std_dev", label: "Std. Dev", decimals: 2 },
{ key: "percentile_5", label: "Percentile 5", decimals: 2 },
{ key: "percentile_95", label: "Percentile 95", decimals: 2 },
{ key: "value_at_risk_95", label: "Value at Risk (95%)", decimals: 2 },
{ key: "expected_shortfall_95", label: "Expected Shortfall (95%)", decimals: 2 },
];
const tableWrapper = document.getElementById("reporting-table-wrapper");
const tableBody = document.getElementById("reporting-table-body");
const emptyState = document.getElementById("reporting-empty");
const refreshButton = document.getElementById("report-refresh");
const feedbackEl = document.getElementById("report-feedback");
function formatNumber(value, decimals = 2) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return "—";
}
return Number(value).toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
function showFeedback(message, type = "success") {
if (!feedbackEl) {
return;
}
feedbackEl.textContent = message;
feedbackEl.classList.remove("hidden", "success", "error");
feedbackEl.classList.add(type);
}
function hideFeedback() {
if (!feedbackEl) {
return;
}
feedbackEl.classList.add("hidden");
feedbackEl.textContent = "";
}
function renderReportingTable(summaryData) {
if (!tableBody || !tableWrapper || !emptyState) {
return;
}
tableBody.innerHTML = "";
if (!summaryData.length) {
emptyState.classList.remove("hidden");
tableWrapper.classList.add("hidden");
return;
}
emptyState.classList.add("hidden");
tableWrapper.classList.remove("hidden");
summaryData.forEach((entry) => {
const row = document.createElement("tr");
const scenarioCell = document.createElement("td");
scenarioCell.textContent = entry.scenario_name;
row.appendChild(scenarioCell);
REPORT_FIELDS.forEach((field) => {
const cell = document.createElement("td");
const source = field.key === "iterations" ? entry : entry.summary || {};
cell.textContent = formatNumber(source[field.key], field.decimals);
row.appendChild(cell);
});
tableBody.appendChild(row);
});
}
async function refreshMetrics() {
hideFeedback();
showFeedback("Refreshing metrics…", "success");
try {
const response = await fetch("/ui/reporting", {
method: "GET",
headers: {
"X-Requested-With": "XMLHttpRequest",
},
});
if (!response.ok) {
throw new Error("Unable to refresh reporting data.");
}
const text = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, "text/html");
const newTable = doc.querySelector("#reporting-table-wrapper");
const newFeedback = doc.querySelector("#report-feedback");
if (!newTable) {
throw new Error("Unexpected response while refreshing.");
}
const newEmptyState = doc.querySelector("#reporting-empty");
if (emptyState && newEmptyState) {
emptyState.className = newEmptyState.className;
emptyState.textContent = newEmptyState.textContent;
}
if (tableWrapper) {
tableWrapper.className = newTable.className;
tableWrapper.innerHTML = newTable.innerHTML;
}
if (newFeedback && feedbackEl) {
feedbackEl.className = newFeedback.className;
feedbackEl.textContent = newFeedback.textContent;
}
showFeedback("Metrics refreshed.", "success");
} catch (error) {
showFeedback(error.message || "An unexpected error occurred.", "error");
}
}
renderReportingTable(reportingSummaries);
if (refreshButton) {
refreshButton.addEventListener("click", refreshMetrics);
}
</script>
{% endblock %}

View File

@@ -1,8 +1,447 @@
{% extends "base.html" %}
{% block title %}Simulations · CalMiner{% endblock %}
{% extends "base.html" %} {% block title %}Simulations · CalMiner{% endblock %}
{% block content %}
<section class="panel">
<h2>Monte Carlo Simulations</h2>
<p>Placeholder for running simulations and reviewing outputs. Target integration: `/api/simulations/run`.</p>
{% if simulation_scenarios %}
<div class="form-grid">
<label for="simulations-scenario-filter">
Scenario filter
<select id="simulations-scenario-filter">
<option value="">Select a scenario</option>
{% for scenario in simulation_scenarios %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
{% endfor %}
</select>
</label>
</div>
{% else %}
<p class="empty-state">Create a scenario before running simulations.</p>
{% endif %}
<div
id="simulations-overview-wrapper"
class="table-container{% if not simulation_scenarios %} hidden{% endif %}"
>
<h3>Scenario Run History</h3>
<table aria-label="Simulation run history">
<thead>
<tr>
<th scope="col">Scenario</th>
<th scope="col">Iterations</th>
<th scope="col">Mean Result</th>
</tr>
</thead>
<tbody id="simulations-overview-body"></tbody>
</table>
</div>
<p id="simulations-overview-empty" class="empty-state hidden">
Create a scenario to review simulation history.
</p>
<div id="simulations-empty" class="empty-state">
Select a scenario to review simulation outputs.
</div>
<div id="simulations-summary-wrapper" class="table-container hidden">
<h3>Summary Metrics</h3>
<table aria-label="Simulation summary metrics">
<thead>
<tr>
<th scope="col">Metric</th>
<th scope="col">Value</th>
</tr>
</thead>
<tbody id="simulations-summary-body"></tbody>
</table>
</div>
<p id="simulations-summary-empty" class="empty-state hidden">
No simulations have been run for this scenario yet.
</p>
<div id="simulations-results-wrapper" class="table-container hidden">
<h3>Sample Results</h3>
<table aria-label="Simulation sample results">
<thead>
<tr>
<th scope="col">Iteration</th>
<th scope="col">Result</th>
</tr>
</thead>
<tbody id="simulations-results-body"></tbody>
</table>
</div>
<p id="simulations-results-empty" class="empty-state hidden">
No sample results available for this scenario.
</p>
</section>
<section class="panel">
<h2>Run Simulation</h2>
{% if simulation_scenarios %}
<form id="simulation-run-form" class="form-grid">
<label for="simulation-form-scenario">
Scenario
<select id="simulation-form-scenario" name="scenario_id" required>
<option value="" disabled selected>Select a scenario</option>
{% for scenario in simulation_scenarios %}
<option value="{{ scenario.id }}">{{ scenario.name }}</option>
{% endfor %}
</select>
</label>
<label for="simulation-form-iterations">
Iterations
<input
id="simulation-form-iterations"
type="number"
name="iterations"
min="100"
step="100"
value="1000"
required
/>
</label>
<label for="simulation-form-seed">
Seed (optional)
<input id="simulation-form-seed" type="number" name="seed" />
</label>
<button type="submit" class="btn primary">Run Simulation</button>
</form>
<p id="simulation-feedback" class="feedback hidden" role="status"></p>
{% else %}
<p class="empty-state">Create at least one scenario to run simulations.</p>
{% endif %}
</section>
<script>
const simulationScenarios = {{ simulation_scenarios | tojson | safe }};
const initialRuns = {{ simulation_runs | tojson | safe }};
const SUMMARY_FIELDS = [
{ key: "count", label: "Iterations", decimals: 0 },
{ key: "mean", label: "Mean Result", decimals: 2 },
{ key: "median", label: "Median Result", decimals: 2 },
{ key: "min", label: "Minimum", decimals: 2 },
{ key: "max", label: "Maximum", decimals: 2 },
{ key: "variance", label: "Variance", decimals: 2 },
{ key: "std_dev", label: "Standard Deviation", decimals: 2 },
{ key: "percentile_5", label: "Percentile 5", decimals: 2 },
{ key: "percentile_95", label: "Percentile 95", decimals: 2 },
{ key: "value_at_risk_95", label: "Value at Risk (95%)", decimals: 2 },
{ key: "expected_shortfall_95", label: "Expected Shortfall (95%)", decimals: 2 },
];
const SAMPLE_RESULT_LIMIT = 20;
const filterSelect = document.getElementById("simulations-scenario-filter");
const overviewWrapper = document.getElementById("simulations-overview-wrapper");
const overviewBody = document.getElementById("simulations-overview-body");
const overviewEmpty = document.getElementById("simulations-overview-empty");
const emptyState = document.getElementById("simulations-empty");
const summaryWrapper = document.getElementById("simulations-summary-wrapper");
const summaryBody = document.getElementById("simulations-summary-body");
const summaryEmpty = document.getElementById("simulations-summary-empty");
const resultsWrapper = document.getElementById("simulations-results-wrapper");
const resultsBody = document.getElementById("simulations-results-body");
const resultsEmpty = document.getElementById("simulations-results-empty");
const simulationForm = document.getElementById("simulation-run-form");
const simulationFeedback = document.getElementById("simulation-feedback");
const formScenarioSelect = document.getElementById("simulation-form-scenario");
const simulationRunsMap = Object.create(null);
function getScenarioName(id) {
const match = simulationScenarios.find(
(scenario) => String(scenario.id) === String(id)
);
return match ? match.name : `Scenario ${id}`;
}
function formatNumber(value, decimals = 2) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return "—";
}
return Number(value).toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
function showFeedback(element, message, type = "success") {
if (!element) {
return;
}
element.textContent = message;
element.classList.remove("hidden", "success", "error");
element.classList.add(type);
}
function hideFeedback(element) {
if (!element) {
return;
}
element.classList.add("hidden");
element.textContent = "";
}
function initializeRunsMap() {
simulationScenarios.forEach((scenario) => {
const key = String(scenario.id);
simulationRunsMap[key] = {
scenario_id: scenario.id,
scenario_name: scenario.name,
iterations: 0,
summary: null,
sample_results: [],
};
});
initialRuns.forEach((run) => {
const key = String(run.scenario_id);
simulationRunsMap[key] = {
scenario_id: run.scenario_id,
scenario_name: run.scenario_name || getScenarioName(key),
iterations: run.iterations || 0,
summary: run.summary || null,
sample_results: Array.isArray(run.sample_results)
? run.sample_results
: [],
};
});
}
function renderOverviewTable() {
if (!overviewBody) {
return;
}
overviewBody.innerHTML = "";
if (!simulationScenarios.length) {
if (overviewWrapper) {
overviewWrapper.classList.add("hidden");
}
if (overviewEmpty) {
overviewEmpty.classList.remove("hidden");
}
return;
}
if (overviewWrapper) {
overviewWrapper.classList.remove("hidden");
}
if (overviewEmpty) {
overviewEmpty.classList.add("hidden");
}
simulationScenarios.forEach((scenario) => {
const key = String(scenario.id);
const run = simulationRunsMap[key];
const iterations = run && run.iterations ? run.iterations : 0;
const meanValue = iterations && run && run.summary ? run.summary.mean : null;
const row = document.createElement("tr");
row.innerHTML = `
<td>${scenario.name}</td>
<td>${iterations || 0}</td>
<td>${iterations ? formatNumber(meanValue) : "—"}</td>
`;
overviewBody.appendChild(row);
});
}
function renderScenarioDetails(scenarioId) {
if (!scenarioId) {
if (emptyState) {
emptyState.classList.remove("hidden");
}
if (summaryWrapper) {
summaryWrapper.classList.add("hidden");
}
if (summaryEmpty) {
summaryEmpty.classList.add("hidden");
}
if (resultsWrapper) {
resultsWrapper.classList.add("hidden");
}
if (resultsEmpty) {
resultsEmpty.classList.add("hidden");
}
return;
}
if (emptyState) {
emptyState.classList.add("hidden");
}
const run = simulationRunsMap[scenarioId];
const iterations = run && run.iterations ? run.iterations : 0;
const summary = run ? run.summary : null;
if (!iterations || !summary) {
if (summaryWrapper) {
summaryWrapper.classList.add("hidden");
}
if (summaryEmpty) {
summaryEmpty.classList.remove("hidden");
}
} else {
if (summaryEmpty) {
summaryEmpty.classList.add("hidden");
}
if (summaryWrapper) {
summaryWrapper.classList.remove("hidden");
}
if (summaryBody) {
summaryBody.innerHTML = "";
SUMMARY_FIELDS.forEach((field) => {
const value = summary[field.key];
const row = document.createElement("tr");
row.innerHTML = `
<td>${field.label}</td>
<td>${formatNumber(value, field.decimals)}</td>
`;
summaryBody.appendChild(row);
});
}
}
const sample = run && Array.isArray(run.sample_results) ? run.sample_results : [];
if (!sample.length) {
if (resultsWrapper) {
resultsWrapper.classList.add("hidden");
}
if (resultsEmpty) {
resultsEmpty.classList.remove("hidden");
}
} else {
if (resultsEmpty) {
resultsEmpty.classList.add("hidden");
}
if (resultsWrapper) {
resultsWrapper.classList.remove("hidden");
}
if (resultsBody) {
resultsBody.innerHTML = "";
sample.forEach((entry) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${entry.iteration}</td>
<td>${formatNumber(entry.result)}</td>
`;
resultsBody.appendChild(row);
});
}
}
}
function syncFormScenario(value) {
if (formScenarioSelect) {
formScenarioSelect.value = value || "";
}
}
function handleScenarioChange(event) {
const value = event.target.value;
renderScenarioDetails(value);
syncFormScenario(value);
renderOverviewTable();
}
async function submitSimulation(event) {
event.preventDefault();
hideFeedback(simulationFeedback);
const formData = new FormData(simulationForm);
const scenarioId = formData.get("scenario_id");
const iterationsValue = Number(formData.get("iterations"));
const seedValue = formData.get("seed");
if (!scenarioId) {
showFeedback(simulationFeedback, "Select a scenario before running a simulation.", "error");
return;
}
if (!iterationsValue || iterationsValue <= 0) {
showFeedback(simulationFeedback, "Provide a positive number of iterations.", "error");
return;
}
const payload = {
scenario_id: Number(scenarioId),
iterations: iterationsValue,
};
if (seedValue) {
payload.seed = Number(seedValue);
}
try {
const response = await fetch("/api/simulations/run", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorDetail = await response.json().catch(() => ({}));
throw new Error(errorDetail.detail || "Unable to start simulation.");
}
const result = await response.json();
const mapKey = String(result.scenario_id);
const scenarioName = getScenarioName(mapKey);
const sampleResults = Array.isArray(result.results)
? result.results.slice(0, SAMPLE_RESULT_LIMIT).map((entry) => ({
iteration: entry.iteration,
result: entry.result,
}))
: [];
simulationRunsMap[mapKey] = {
scenario_id: result.scenario_id,
scenario_name: scenarioName,
iterations:
(result.summary && Number(result.summary.count)) || result.iterations || sampleResults.length,
summary: result.summary || null,
sample_results: sampleResults,
};
simulationForm.reset();
showFeedback(simulationFeedback, "Simulation completed successfully.", "success");
if (formScenarioSelect) {
formScenarioSelect.value = mapKey;
}
if (filterSelect) {
filterSelect.value = mapKey;
}
renderOverviewTable();
renderScenarioDetails(mapKey);
} catch (error) {
showFeedback(
simulationFeedback,
error.message || "An unexpected error occurred.",
"error"
);
}
}
initializeRunsMap();
renderOverviewTable();
if (filterSelect) {
filterSelect.addEventListener("change", handleScenarioChange);
}
if (simulationForm) {
simulationForm.addEventListener("submit", submitSimulation);
}
if (filterSelect && filterSelect.value) {
renderScenarioDetails(filterSelect.value);
syncFormScenario(filterSelect.value);
}
</script>
{% endblock %}