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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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