Refactor templates to externalize JavaScript: Moved inline scripts to separate JS files and added JSON data attributes for better maintainability and performance. Updated consumption, costs, equipment, maintenance, production, reporting, and simulations templates accordingly.
This commit is contained in:
@@ -84,493 +84,11 @@ block content %}
|
||||
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 dataElement = document.getElementById("dashboard-data");
|
||||
if (!dataElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let state = {};
|
||||
try {
|
||||
state = JSON.parse(dataElement.textContent || "{}");
|
||||
} catch (error) {
|
||||
console.error("Failed to parse dashboard data", error);
|
||||
return;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
let costChartInstance = null;
|
||||
let activityChartInstance = null;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "bottom",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderActivityChart() {
|
||||
if (!activityChartCanvas || !activityChartEmpty) {
|
||||
return;
|
||||
}
|
||||
if (activityChartInstance) {
|
||||
activityChartInstance.destroy();
|
||||
activityChartInstance = null;
|
||||
}
|
||||
const hasData =
|
||||
Boolean(state.activity_chart_has_data) &&
|
||||
Array.isArray(state.scenario_activity_chart?.labels) &&
|
||||
state.scenario_activity_chart.labels.length > 0;
|
||||
activityChartCanvas.hidden = !hasData;
|
||||
activityChartEmpty.hidden = hasData;
|
||||
if (!hasData) {
|
||||
return;
|
||||
}
|
||||
activityChartInstance = new Chart(activityChartCanvas.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: state.scenario_activity_chart.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Production",
|
||||
data: state.scenario_activity_chart.production,
|
||||
borderColor: "#22c55e",
|
||||
backgroundColor: "rgba(34, 197, 94, 0.18)",
|
||||
tension: 0.35,
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: "Consumption",
|
||||
data: state.scenario_activity_chart.consumption,
|
||||
borderColor: "#ef4444",
|
||||
backgroundColor: "rgba(239, 68, 68, 0.18)",
|
||||
tension: 0.35,
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "bottom",
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
renderSummaryMetrics();
|
||||
renderScenarioTable();
|
||||
renderOverallMetrics();
|
||||
renderRecentSimulations();
|
||||
renderMaintenance();
|
||||
renderCostChart();
|
||||
renderActivityChart();
|
||||
}
|
||||
|
||||
function setLoading(isLoading) {
|
||||
if (!refreshButton) {
|
||||
return;
|
||||
}
|
||||
refreshButton.disabled = isLoading;
|
||||
refreshButton.classList.toggle("is-loading", isLoading);
|
||||
refreshButton.textContent = isLoading
|
||||
? "Refreshing…"
|
||||
: "Refresh Dashboard";
|
||||
}
|
||||
|
||||
async function refreshDashboard() {
|
||||
try {
|
||||
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);
|
||||
setStatus(error.message || "Unable to refresh dashboard.", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshButton) {
|
||||
refreshButton.addEventListener("click", refreshDashboard);
|
||||
}
|
||||
|
||||
renderDashboard();
|
||||
})();
|
||||
{{ {"summary_metrics": summary_metrics, "scenario_rows": scenario_rows, "overall_report_metrics": overall_report_metrics, "recent_simulations": recent_simulations, "upcoming_maintenance": upcoming_maintenance} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -44,125 +44,7 @@ endblock %} {% block content %}
|
||||
</section>
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="parameters-data" type="application/json">
|
||||
{{ parameters_by_scenario | tojson | safe }}
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dataElement = document.getElementById("parameters-data");
|
||||
const parsedData = dataElement
|
||||
? JSON.parse(dataElement.textContent || "{}")
|
||||
: {};
|
||||
const parametersByScenario =
|
||||
parsedData && typeof parsedData === "object" ? parsedData : {};
|
||||
const form = document.getElementById("parameter-form");
|
||||
const scenarioSelect = /** @type {HTMLSelectElement | null} */ (
|
||||
document.getElementById("scenario_id")
|
||||
);
|
||||
const nameInput = /** @type {HTMLInputElement | null} */ (
|
||||
document.getElementById("name")
|
||||
);
|
||||
const valueInput = /** @type {HTMLInputElement | null} */ (
|
||||
document.getElementById("value")
|
||||
);
|
||||
const feedback = document.getElementById("parameter-feedback");
|
||||
const tableBody = document.getElementById("parameter-table-body");
|
||||
|
||||
const setFeedback = (message, variant) => {
|
||||
if (!feedback) {
|
||||
return;
|
||||
}
|
||||
feedback.textContent = message;
|
||||
feedback.classList.remove("success", "error");
|
||||
if (variant) {
|
||||
feedback.classList.add(variant);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTable = (scenarioId) => {
|
||||
if (!tableBody) {
|
||||
return;
|
||||
}
|
||||
tableBody.innerHTML = "";
|
||||
const rows = parametersByScenario[String(scenarioId)] || [];
|
||||
if (!rows.length) {
|
||||
const emptyRow = document.createElement("tr");
|
||||
emptyRow.id = "parameter-empty-state";
|
||||
emptyRow.innerHTML =
|
||||
'<td colspan="4">No parameters recorded for this scenario yet.</td>';
|
||||
tableBody.appendChild(emptyRow);
|
||||
return;
|
||||
}
|
||||
rows.forEach((row) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${row.name}</td>
|
||||
<td>${row.value}</td>
|
||||
<td>${row.distribution_type ?? "—"}</td>
|
||||
<td>${
|
||||
row.distribution_parameters
|
||||
? JSON.stringify(row.distribution_parameters)
|
||||
: "—"
|
||||
}</td>
|
||||
`;
|
||||
tableBody.appendChild(tr);
|
||||
});
|
||||
};
|
||||
|
||||
if (scenarioSelect) {
|
||||
renderTable(scenarioSelect.value);
|
||||
scenarioSelect.addEventListener("change", () =>
|
||||
renderTable(scenarioSelect.value)
|
||||
);
|
||||
}
|
||||
|
||||
if (!form || !scenarioSelect || !nameInput || !valueInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const scenarioId = scenarioSelect.value;
|
||||
const payload = {
|
||||
scenario_id: Number(scenarioId),
|
||||
name: nameInput.value.trim(),
|
||||
value: Number(valueInput.value),
|
||||
};
|
||||
|
||||
if (!payload.name) {
|
||||
setFeedback("Parameter name is required.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(payload.value)) {
|
||||
setFeedback("Enter a numeric value.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/parameters/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
setFeedback(`Error saving parameter: ${errorText}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const scenarioKey = String(scenarioId);
|
||||
parametersByScenario[scenarioKey] =
|
||||
parametersByScenario[scenarioKey] || [];
|
||||
parametersByScenario[scenarioKey].push(data);
|
||||
|
||||
form.reset();
|
||||
scenarioSelect.value = scenarioKey;
|
||||
renderTable(scenarioKey);
|
||||
nameInput.focus();
|
||||
setFeedback("Parameter saved.", "success");
|
||||
});
|
||||
});
|
||||
{{ parameters_by_scenario | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/parameters.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -48,67 +48,5 @@ endblock %} {% block content %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.getElementById("scenario-form");
|
||||
const nameInput = /** @type {HTMLInputElement} */ (
|
||||
document.getElementById("name")
|
||||
);
|
||||
const descriptionInput = /** @type {HTMLInputElement} */ (
|
||||
document.getElementById("description")
|
||||
);
|
||||
const table = document.getElementById("scenario-table");
|
||||
const tableBody = document.getElementById("scenario-table-body");
|
||||
const emptyState = document.getElementById("empty-state");
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const payload = {
|
||||
name: nameInput.value.trim(),
|
||||
description: descriptionInput.value.trim() || null,
|
||||
};
|
||||
|
||||
if (!payload.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/scenarios/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("Scenario creation failed", errorText);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.scenarioId = String(data.id);
|
||||
row.innerHTML = `
|
||||
<td>${data.name}</td>
|
||||
<td>${data.description ?? "—"}</td>
|
||||
`;
|
||||
|
||||
if (emptyState) {
|
||||
emptyState.remove();
|
||||
}
|
||||
|
||||
if (table) {
|
||||
table.classList.remove("hidden");
|
||||
table.removeAttribute("aria-hidden");
|
||||
}
|
||||
|
||||
if (tableBody) {
|
||||
tableBody.appendChild(row);
|
||||
}
|
||||
|
||||
form.reset();
|
||||
nameInput.focus();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/scenario-form.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -71,132 +71,9 @@
|
||||
{% 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);
|
||||
}
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="consumption-data" type="application/json">
|
||||
{{ {"scenarios": scenarios, "consumption": consumption_by_scenario} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/consumption.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -149,200 +149,9 @@ block content %}
|
||||
{% 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);
|
||||
}
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="costs-payload" type="application/json">
|
||||
{{ {"capex": capex_by_scenario, "opex": opex_by_scenario} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/costs.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -66,125 +66,9 @@ block content %}
|
||||
{% 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);
|
||||
}
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="equipment-data" type="application/json">
|
||||
{{ {"scenarios": scenarios, "equipment": equipment_by_scenario} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/equipment.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -100,209 +100,9 @@
|
||||
{% 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);
|
||||
}
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="maintenance-data" type="application/json">
|
||||
{{ {"equipment": equipment_by_scenario, "maintenance": maintenance_by_scenario} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/maintenance.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -73,132 +73,9 @@
|
||||
{% 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);
|
||||
}
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="production-data" type="application/json">
|
||||
{{ {"scenarios": scenarios, "production": production_by_scenario} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/production.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -33,138 +33,9 @@ block content %}
|
||||
</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);
|
||||
}
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="reporting-data" type="application/json">
|
||||
{{ report_summaries | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/reporting.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -31,417 +31,11 @@
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<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 %}
|
||||
{% endblock %} {% block scripts %} {{ super() }}
|
||||
<script id="simulations-data" type="application/json">
|
||||
{{ {"scenarios": simulation_scenarios, "runs": simulation_runs} | tojson }}
|
||||
</script>
|
||||
<script src="/static/js/simulations.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user