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:
19
README.md
19
README.md
@@ -18,7 +18,9 @@ A range of features are implemented to support these functionalities.
|
|||||||
- **Equipment Management**: Register scenario-specific equipment inventories.
|
- **Equipment Management**: Register scenario-specific equipment inventories.
|
||||||
- **Maintenance Logging**: Log maintenance events against equipment with dates and costs.
|
- **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.
|
- **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.
|
- **Monte Carlo Simulation (in progress)**: Services and routes are scaffolded for future stochastic analysis.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -79,19 +81,20 @@ uvicorn main:app --reload
|
|||||||
- `POST /api/equipment/` create equipment records
|
- `POST /api/equipment/` create equipment records
|
||||||
- `POST /api/maintenance/` log maintenance events
|
- `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%)
|
- `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):
|
- **UI entries** (rendered via FastAPI templates, also reachable from the sidebar):
|
||||||
- `GET /ui/dashboard` reporting dashboard
|
- `GET /` operations overview dashboard
|
||||||
|
- `GET /ui/dashboard` legacy dashboard alias
|
||||||
- `GET /ui/scenarios` scenario creation form
|
- `GET /ui/scenarios` scenario creation form
|
||||||
- `GET /ui/parameters` parameter input 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
|
- `GET /ui/costs`, `/ui/consumption`, `/ui/production`, `/ui/equipment`, `/ui/maintenance`, `/ui/simulations`, `/ui/reporting` placeholder views aligned with future integrations
|
||||||
|
|
||||||
### Dashboard Preview
|
### 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`).
|
1. Start the FastAPI server and navigate to `/`.
|
||||||
2. Use the "Load Sample Data" button to populate the JSON textarea with demo results.
|
2. Review the headline metrics, scenario snapshot table, and cost/activity charts sourced from the current database state.
|
||||||
3. Select "Refresh Dashboard" to post the dataset to `/api/reporting/summary` and render the returned statistics and distribution chart.
|
3. Use the "Refresh Dashboard" button to pull freshly aggregated data via `/ui/dashboard/data` without reloading the page.
|
||||||
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.
|
4. Populate scenarios, costs, production, consumption, simulations, and maintenance records to see charts and lists update.
|
||||||
5. If the summary endpoint is unavailable, the dashboard displays an inline error—refresh once the API is reachable.
|
5. The legacy `/ui/dashboard` route remains available but now serves the same consolidated overview.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **Middleware** (`middleware/validation.py`): applies JSON validation before requests reach routers.
|
||||||
- **Testing** (`tests/unit/`): pytest suite covering route and service behavior.
|
- **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
|
### 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.
|
- 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 calls `/api/reporting/summary` with raw simulation outputs and expects `result` fields, so any upstream changes to the reporting contract must maintain this schema.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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
|
### Reporting Pipeline and UI Integration
|
||||||
|
|
||||||
|
|||||||
506
routes/ui.py
506
routes/ui.py
@@ -3,13 +3,21 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy.orm import Session
|
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.parameters import Parameter
|
||||||
|
from models.production_output import ProductionOutput
|
||||||
from models.scenario import Scenario
|
from models.scenario import Scenario
|
||||||
|
from models.simulation_result import SimulationResult
|
||||||
from routes.dependencies import get_db
|
from routes.dependencies import get_db
|
||||||
|
from services.reporting import generate_report
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -35,6 +43,18 @@ def _render(
|
|||||||
return templates.TemplateResponse(template_name, _context(request, extra))
|
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]:
|
def _load_scenarios(db: Session) -> Dict[str, Any]:
|
||||||
scenarios: list[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)}
|
return {"parameters_by_scenario": dict(grouped)}
|
||||||
|
|
||||||
|
|
||||||
def _load_costs(_: Session) -> Dict[str, Any]:
|
def _load_costs(db: Session) -> Dict[str, Any]:
|
||||||
return {"capex_entries": [], "opex_entries": []}
|
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]:
|
def _load_consumption(db: Session) -> Dict[str, Any]:
|
||||||
return {"consumption_records": []}
|
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]:
|
def _load_production(db: Session) -> Dict[str, Any]:
|
||||||
return {"production_records": []}
|
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]:
|
def _load_equipment(db: Session) -> Dict[str, Any]:
|
||||||
return {"equipment_items": []}
|
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]:
|
def _load_maintenance(db: Session) -> Dict[str, Any]:
|
||||||
return {"maintenance_records": []}
|
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]:
|
def _load_simulations(db: Session) -> Dict[str, Any]:
|
||||||
return {"simulation_runs": []}
|
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]:
|
def _load_reporting(db: Session) -> Dict[str, Any]:
|
||||||
return {"report_summaries": []}
|
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)
|
@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)
|
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)
|
@router.get("/ui/parameters", response_class=HTMLResponse)
|
||||||
async def parameter_form(request: Request, db: Session = Depends(get_db)):
|
async def parameter_form(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Render the parameter input form."""
|
"""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)
|
@router.get("/ui/costs", response_class=HTMLResponse)
|
||||||
async def costs_view(request: Request, db: Session = Depends(get_db)):
|
async def costs_view(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Render the costs placeholder view."""
|
"""Render the costs view with CAPEX and OPEX data."""
|
||||||
return _render(request, "costs.html", _load_costs(db))
|
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)
|
@router.get("/ui/consumption", response_class=HTMLResponse)
|
||||||
async def consumption_view(request: Request, db: Session = Depends(get_db)):
|
async def consumption_view(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Render the consumption placeholder view."""
|
"""Render the consumption view with scenario consumption data."""
|
||||||
return _render(request, "consumption.html", _load_consumption(db))
|
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)
|
@router.get("/ui/production", response_class=HTMLResponse)
|
||||||
async def production_view(request: Request, db: Session = Depends(get_db)):
|
async def production_view(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Render the production placeholder view."""
|
"""Render the production view with scenario production data."""
|
||||||
return _render(request, "production.html", _load_production(db))
|
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)
|
@router.get("/ui/equipment", response_class=HTMLResponse)
|
||||||
async def equipment_view(request: Request, db: Session = Depends(get_db)):
|
async def equipment_view(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Render the equipment placeholder view."""
|
"""Render the equipment view with scenario equipment data."""
|
||||||
return _render(request, "equipment.html", _load_equipment(db))
|
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)
|
@router.get("/ui/maintenance", response_class=HTMLResponse)
|
||||||
async def maintenance_view(request: Request, db: Session = Depends(get_db)):
|
async def maintenance_view(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Render the maintenance placeholder view."""
|
"""Render the maintenance view with scenario maintenance data."""
|
||||||
return _render(request, "maintenance.html", _load_maintenance(db))
|
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)
|
@router.get("/ui/simulations", response_class=HTMLResponse)
|
||||||
async def simulations_view(request: Request, db: Session = Depends(get_db)):
|
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))
|
return _render(request, "simulations.html", _load_simulations(db))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ui/reporting", response_class=HTMLResponse)
|
@router.get("/ui/reporting", response_class=HTMLResponse)
|
||||||
async def reporting_view(request: Request, db: Session = Depends(get_db)):
|
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))
|
return _render(request, "reporting.html", _load_reporting(db))
|
||||||
|
|||||||
@@ -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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
background-color: #f4f5f7;
|
background-color: var(--color-background);
|
||||||
color: #1f2933;
|
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 {
|
.container {
|
||||||
width: min(960px, 92%);
|
width: min(960px, 92%);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header {
|
.app-content.container {
|
||||||
background-color: #0b3d91;
|
width: min(1120px, 92%);
|
||||||
color: #ffffff;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-inner {
|
.dashboard-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-title {
|
.dashboard-subtitle {
|
||||||
font-size: 1.5rem;
|
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;
|
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;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav a {
|
.metric-list li {
|
||||||
color: #ffffff;
|
display: flex;
|
||||||
text-decoration: none;
|
align-items: center;
|
||||||
font-weight: 500;
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav a:hover,
|
.striped-list {
|
||||||
.site-nav a:focus {
|
list-style: none;
|
||||||
text-decoration: underline;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
main.container {
|
.striped-list li {
|
||||||
padding-top: 2rem;
|
padding: 0.85rem 1rem;
|
||||||
padding-bottom: 2rem;
|
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 {
|
.panel {
|
||||||
background-color: #ffffff;
|
background-color: var(--color-surface);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.5rem;
|
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;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,14 +335,14 @@ main.container {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #111827;
|
color: var(--color-text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid input,
|
.form-grid input,
|
||||||
.form-grid textarea,
|
.form-grid textarea,
|
||||||
.form-grid select {
|
.form-grid select {
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
border: 1px solid #cbd5e1;
|
border: 1px solid var(--color-border-strong);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
@@ -84,7 +350,7 @@ main.container {
|
|||||||
.form-grid input:focus,
|
.form-grid input:focus,
|
||||||
.form-grid textarea:focus,
|
.form-grid textarea:focus,
|
||||||
.form-grid select:focus {
|
.form-grid select:focus {
|
||||||
outline: 2px solid #2563eb;
|
outline: 2px solid var(--color-primary-strong);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,30 +364,30 @@ main.container {
|
|||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background-color: #e2e8f0;
|
background-color: var(--color-border);
|
||||||
color: #0f172a;
|
color: var(--color-text-dark);
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover,
|
.btn:hover,
|
||||||
.btn:focus {
|
.btn:focus {
|
||||||
transform: translateY(-1px);
|
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 {
|
.btn.primary {
|
||||||
background-color: #2563eb;
|
background-color: var(--color-primary-strong);
|
||||||
color: #ffffff;
|
color: var(--color-text-invert);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.primary:hover,
|
.btn.primary:hover,
|
||||||
.btn.primary:focus {
|
.btn.primary:focus {
|
||||||
background-color: #1d4ed8;
|
background-color: var(--color-primary-stronger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-output {
|
.result-output {
|
||||||
background-color: #0f172a;
|
background-color: var(--color-text-dark);
|
||||||
color: #f8fafc;
|
color: var(--color-surface-alt);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-family: "Fira Code", "Consolas", "Courier New", monospace;
|
font-family: "Fira Code", "Consolas", "Courier New", monospace;
|
||||||
@@ -152,28 +418,28 @@ table {
|
|||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #f8fafc;
|
background-color: var(--color-surface-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
thead {
|
thead {
|
||||||
background-color: #0b3d91;
|
background-color: var(--color-primary);
|
||||||
color: #ffffff;
|
color: var(--color-text-invert);
|
||||||
}
|
}
|
||||||
|
|
||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:nth-child(even) {
|
tbody tr:nth-child(even) {
|
||||||
background-color: #eef2ff;
|
background-color: var(--color-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
color: #64748b;
|
color: var(--color-text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,16 +453,16 @@ tbody tr:nth-child(even) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feedback.success {
|
.feedback.success {
|
||||||
color: #047857;
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.feedback.error {
|
.feedback.error {
|
||||||
color: #b91c1c;
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-footer {
|
.site-footer {
|
||||||
background-color: #0b3d91;
|
background-color: var(--color-primary);
|
||||||
color: #ffffff;
|
color: var(--color-text-invert);
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,3 +473,55 @@ tbody tr:nth-child(even) {
|
|||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
font-size: 0.9rem;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,195 +1,517 @@
|
|||||||
{% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {%
|
{% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {%
|
||||||
block head_extra %} {{ super() }}
|
block content %}
|
||||||
<style>
|
<div class="dashboard-header">
|
||||||
.summary-card {
|
<div>
|
||||||
background: #ffffff;
|
<h2>Operations Overview</h2>
|
||||||
border-radius: 8px;
|
<p class="dashboard-subtitle">
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
Unified insight across scenarios, costs, production, maintenance, and
|
||||||
padding: 1.5rem;
|
simulations.
|
||||||
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.
|
|
||||||
</p>
|
</p>
|
||||||
<textarea id="results-input" rows="6" class="monospace-input"></textarea>
|
</div>
|
||||||
<div class="button-row">
|
<div class="dashboard-actions">
|
||||||
<button id="load-sample" type="button" class="btn">Load Sample Data</button>
|
|
||||||
<button id="refresh-dashboard" type="button" class="btn primary">
|
<button id="refresh-dashboard" type="button" class="btn primary">
|
||||||
Refresh Dashboard
|
Refresh Dashboard
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="chart-container">
|
|
||||||
<h3>Result Distribution</h3>
|
<p id="dashboard-status" class="feedback" hidden></p>
|
||||||
<canvas id="summary-chart" height="120"></canvas>
|
|
||||||
|
<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>
|
</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() }}
|
{% endblock %} {% block scripts %} {{ super() }}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<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>
|
<script>
|
||||||
const SUMMARY_FIELDS = [
|
(() => {
|
||||||
{ key: "mean", label: "Mean" },
|
const dataElement = document.getElementById("dashboard-data");
|
||||||
{ key: "median", label: "Median" },
|
if (!dataElement) {
|
||||||
{ key: "min", label: "Min" },
|
return;
|
||||||
{ 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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
let state = {};
|
||||||
}
|
|
||||||
|
|
||||||
function getResultsFromInput() {
|
|
||||||
const textarea = document.getElementById("results-input");
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(textarea.value || "[]");
|
state = JSON.parse(dataElement.textContent || "{}");
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
throw new Error("Input must be a JSON array");
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Invalid JSON input: ${error.message}`);
|
console.error("Failed to parse dashboard data", error);
|
||||||
}
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSummary(summary) {
|
const statusElement = document.getElementById("dashboard-status");
|
||||||
const grid = document.getElementById("summary-grid");
|
const summaryContainer = document.getElementById("summary-metrics");
|
||||||
grid.innerHTML = "";
|
const summaryEmpty = document.getElementById("summary-empty");
|
||||||
SUMMARY_FIELDS.forEach(({ key, label }) => {
|
const scenarioTableBody = document.querySelector("#scenario-table tbody");
|
||||||
const rawValue = summary[key];
|
const scenarioEmpty = document.getElementById("scenario-table-empty");
|
||||||
const numericValue = Number(rawValue);
|
const overallMetricsList = document.getElementById("overall-metrics");
|
||||||
const display = Number.isFinite(numericValue)
|
const overallMetricsEmpty = document.getElementById(
|
||||||
? numericValue.toFixed(2)
|
"overall-metrics-empty"
|
||||||
: "—";
|
|
||||||
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 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 = [
|
let costChartInstance = null;
|
||||||
{ label: "VaR (95%)", value: summary.value_at_risk_95 },
|
let activityChartInstance = null;
|
||||||
{ label: "ES (95%)", value: summary.expected_shortfall_95 },
|
|
||||||
]
|
function setStatus(message, variant = "success") {
|
||||||
.map(({ label, value }) => {
|
if (!statusElement) {
|
||||||
const numeric = Number(value);
|
return;
|
||||||
if (!Number.isFinite(numeric)) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
return `${label}: ${numeric.toFixed(2)}`;
|
if (!message) {
|
||||||
})
|
statusElement.hidden = true;
|
||||||
.filter((line) => line !== null);
|
statusElement.textContent = "";
|
||||||
|
statusElement.classList.remove("success", "error");
|
||||||
if (chartInstance) {
|
return;
|
||||||
chartInstance.destroy();
|
}
|
||||||
|
statusElement.textContent = message;
|
||||||
|
statusElement.hidden = false;
|
||||||
|
statusElement.classList.toggle("success", variant === "success");
|
||||||
|
statusElement.classList.toggle("error", variant !== "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
chartInstance = new Chart(ctx, {
|
function renderSummaryMetrics() {
|
||||||
type: "line",
|
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: {
|
data: {
|
||||||
labels,
|
labels: state.scenario_cost_chart.labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: "Result Summary",
|
label: "CAPEX",
|
||||||
data: dataPoints,
|
data: state.scenario_cost_chart.capex,
|
||||||
borderColor: "#2563eb",
|
backgroundColor: "#1d4ed8",
|
||||||
backgroundColor: "rgba(37, 99, 235, 0.2)",
|
},
|
||||||
tension: 0.3,
|
{
|
||||||
|
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,
|
fill: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: false },
|
legend: {
|
||||||
tooltip: {
|
position: "bottom",
|
||||||
callbacks: {
|
|
||||||
afterBody: () => tailRiskLines,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
@@ -201,61 +523,54 @@ block head_extra %} {{ super() }}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function showError(message) {
|
function renderDashboard() {
|
||||||
const errorElement = document.getElementById("error-message");
|
renderSummaryMetrics();
|
||||||
errorElement.textContent = message;
|
renderScenarioTable();
|
||||||
errorElement.hidden = false;
|
renderOverallMetrics();
|
||||||
|
renderRecentSimulations();
|
||||||
|
renderMaintenance();
|
||||||
|
renderCostChart();
|
||||||
|
renderActivityChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachHandlers() {
|
function setLoading(isLoading) {
|
||||||
const loadSampleButton = document.getElementById("load-sample");
|
if (!refreshButton) {
|
||||||
const refreshButton = document.getElementById("refresh-dashboard");
|
return;
|
||||||
|
}
|
||||||
|
refreshButton.disabled = isLoading;
|
||||||
|
refreshButton.classList.toggle("is-loading", isLoading);
|
||||||
|
refreshButton.textContent = isLoading
|
||||||
|
? "Refreshing…"
|
||||||
|
: "Refresh Dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
const sampleData = JSON.stringify(
|
async function refreshDashboard() {
|
||||||
[
|
|
||||||
{ 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 () => {
|
|
||||||
try {
|
try {
|
||||||
const results = getResultsFromInput();
|
setLoading(true);
|
||||||
const summary = await fetchSummary(results);
|
setStatus("");
|
||||||
renderSummary(summary);
|
const response = await fetch("/ui/dashboard/data", {
|
||||||
renderChart(summary);
|
headers: { Accept: "application/json" },
|
||||||
document.getElementById("error-message").hidden = true;
|
});
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
showError(error.message);
|
setStatus(error.message || "Unable to refresh dashboard.", "error");
|
||||||
}
|
} finally {
|
||||||
});
|
setLoading(false);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeDashboard();
|
if (refreshButton) {
|
||||||
|
refreshButton.addEventListener("click", refreshDashboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDashboard();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,10 +8,17 @@
|
|||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="app-sidebar" aria-label="Primary navigation">
|
||||||
{% include "partials/base_header.html" %}
|
{% 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 %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
{% include "partials/base_footer.html" %} {% block scripts %}{% endblock %}
|
{% include "partials/base_footer.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,8 +1,202 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %} {% block title %}Consumption · CalMiner{% endblock %}
|
||||||
{% block title %}Consumption · CalMiner{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Consumption Tracking</h2>
|
<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>
|
||||||
|
|
||||||
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,8 +1,348 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %} {% block title %}Costs · CalMiner{% endblock %} {%
|
||||||
{% block title %}Costs · CalMiner{% endblock %}
|
block content %}
|
||||||
{% block content %}
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Costs</h2>
|
<h2>Cost Overview</h2>
|
||||||
<p>This view will surface CAPEX and OPEX entries tied to scenarios. API wiring pending.</p>
|
{% 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>
|
||||||
|
|
||||||
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,8 +1,190 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %} {% block title %}Equipment · CalMiner{% endblock %} {%
|
||||||
{% block title %}Equipment · CalMiner{% endblock %}
|
block content %}
|
||||||
{% block content %}
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Equipment Inventory</h2>
|
<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>
|
||||||
|
|
||||||
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,8 +1,308 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %} {% block title %}Maintenance · CalMiner{% endblock %}
|
||||||
{% block title %}Maintenance · CalMiner{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Maintenance Log</h2>
|
<h2>Maintenance Schedule</h2>
|
||||||
<p>Placeholder for maintenance records management. Future work will surface CRUD flows tied to `/api/maintenance/`.</p>
|
{% 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>
|
||||||
|
|
||||||
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,17 +1,38 @@
|
|||||||
<header class="site-header">
|
{% set nav_links = [
|
||||||
<div class="container header-inner">
|
("/", "Dashboard"),
|
||||||
<h1 class="site-title">CalMiner</h1>
|
("/ui/scenarios", "Scenarios"),
|
||||||
<nav class="site-nav" aria-label="Primary navigation">
|
("/ui/parameters", "Parameters"),
|
||||||
<a href="/ui/dashboard">Dashboard</a>
|
("/ui/costs", "Costs"),
|
||||||
<a href="/ui/scenarios">Scenarios</a>
|
("/ui/consumption", "Consumption"),
|
||||||
<a href="/ui/parameters">Parameters</a>
|
("/ui/production", "Production"),
|
||||||
<a href="/ui/costs">Costs</a>
|
("/ui/equipment", "Equipment"),
|
||||||
<a href="/ui/consumption">Consumption</a>
|
("/ui/maintenance", "Maintenance"),
|
||||||
<a href="/ui/production">Production</a>
|
("/ui/simulations", "Simulations"),
|
||||||
<a href="/ui/equipment">Equipment</a>
|
("/ui/reporting", "Reporting"),
|
||||||
<a href="/ui/maintenance">Maintenance</a>
|
] %}
|
||||||
<a href="/ui/simulations">Simulations</a>
|
|
||||||
<a href="/ui/reporting">Reporting</a>
|
<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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|||||||
@@ -1,8 +1,204 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %} {% block title %}Production · CalMiner{% endblock %}
|
||||||
{% block title %}Production · CalMiner{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Production Output</h2>
|
<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>
|
||||||
|
|
||||||
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,8 +1,170 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %} {% block title %}Reporting · CalMiner{% endblock %} {%
|
||||||
{% block title %}Reporting · CalMiner{% endblock %}
|
block content %}
|
||||||
{% block content %}
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Reporting</h2>
|
<h2>Scenario KPI Summary</h2>
|
||||||
<p>Placeholder for aggregated KPI views connected to `/api/reporting/` endpoints.</p>
|
<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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,8 +1,447 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %} {% block title %}Simulations · CalMiner{% endblock %}
|
||||||
{% block title %}Simulations · CalMiner{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Monte Carlo Simulations</h2>
|
<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>
|
||||||
|
|
||||||
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user