diff --git a/README.md b/README.md
index 872172f..0e478e8 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ A range of features are implemented to support these functionalities.
- **Unified UI Shell**: Server-rendered templates extend a shared base layout with a persistent left sidebar linking scenarios, parameters, costs, consumption, production, equipment, maintenance, simulations, and reporting views.
- **Operations Overview Dashboard**: The root route (`/`) surfaces cross-scenario KPIs, charts, and maintenance reminders with a one-click refresh backed by aggregated loaders.
- **Theming Tokens**: Shared CSS variables in `static/css/main.css` centralize the UI color palette for consistent styling and rapid theme tweaks.
+- **Modular Frontend Scripts**: Page-specific interactions now live in `static/js/` modules, keeping templates lean while enabling browser caching and reuse.
- **Monte Carlo Simulation (in progress)**: Services and routes are scaffolded for future stochastic analysis.
## Architecture
diff --git a/static/js/consumption.js b/static/js/consumption.js
new file mode 100644
index 0000000..1adfc52
--- /dev/null
+++ b/static/js/consumption.js
@@ -0,0 +1,156 @@
+document.addEventListener("DOMContentLoaded", () => {
+ const dataElement = document.getElementById("consumption-data");
+ let data = { scenarios: [], consumption: {} };
+
+ if (dataElement) {
+ try {
+ const parsed = JSON.parse(dataElement.textContent || "{}");
+ if (parsed && typeof parsed === "object") {
+ data = {
+ scenarios: Array.isArray(parsed.scenarios) ? parsed.scenarios : [],
+ consumption:
+ parsed.consumption && typeof parsed.consumption === "object"
+ ? parsed.consumption
+ : {},
+ };
+ }
+ } catch (error) {
+ console.error("Unable to parse consumption data", error);
+ }
+ }
+
+ const consumptionByScenario = data.consumption;
+ 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");
+
+ const showFeedback = (message, type = "success") => {
+ if (!feedbackEl) {
+ return;
+ }
+ feedbackEl.textContent = message;
+ feedbackEl.classList.remove("hidden", "success", "error");
+ feedbackEl.classList.add(type);
+ };
+
+ const hideFeedback = () => {
+ if (!feedbackEl) {
+ return;
+ }
+ feedbackEl.classList.add("hidden");
+ feedbackEl.textContent = "";
+ };
+
+ const formatAmount = (value) =>
+ Number(value).toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+
+ const renderConsumptionRows = (scenarioId) => {
+ if (!tableBody || !tableWrapper || !emptyState) {
+ return;
+ }
+
+ 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 = `
+
${formatAmount(record.amount)} |
+ ${record.description || "—"} |
+ `;
+ tableBody.appendChild(row);
+ });
+ };
+
+ if (filterSelect) {
+ filterSelect.addEventListener("change", (event) => {
+ const value = event.target.value;
+ if (!value) {
+ if (emptyState && tableWrapper && tableBody) {
+ emptyState.textContent =
+ "Choose a scenario to review its consumption records.";
+ emptyState.classList.remove("hidden");
+ tableWrapper.classList.add("hidden");
+ tableBody.innerHTML = "";
+ }
+ return;
+ }
+ renderConsumptionRows(value);
+ });
+ }
+
+ const submitConsumption = async (event) => {
+ event.preventDefault();
+ hideFeedback();
+
+ if (!form) {
+ return;
+ }
+
+ 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);
+ }
+});
diff --git a/static/js/costs.js b/static/js/costs.js
new file mode 100644
index 0000000..017d899
--- /dev/null
+++ b/static/js/costs.js
@@ -0,0 +1,240 @@
+document.addEventListener("DOMContentLoaded", () => {
+ const dataElement = document.getElementById("costs-payload");
+ let capexByScenario = {};
+ let opexByScenario = {};
+
+ if (dataElement) {
+ try {
+ const parsed = JSON.parse(dataElement.textContent || "{}");
+ if (parsed && typeof parsed === "object") {
+ if (parsed.capex && typeof parsed.capex === "object") {
+ capexByScenario = parsed.capex;
+ }
+ if (parsed.opex && typeof parsed.opex === "object") {
+ opexByScenario = parsed.opex;
+ }
+ }
+ } catch (error) {
+ console.error("Unable to parse cost data", error);
+ }
+ }
+
+ 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");
+
+ const showFeedback = (element, message, type = "success") => {
+ if (!element) {
+ return;
+ }
+ element.textContent = message;
+ element.classList.remove("hidden", "success", "error");
+ element.classList.add(type);
+ };
+
+ const hideFeedback = (element) => {
+ if (!element) {
+ return;
+ }
+ element.classList.add("hidden");
+ element.textContent = "";
+ };
+
+ const formatAmount = (value) =>
+ Number(value).toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+
+ const sumAmount = (records) =>
+ records.reduce((total, record) => total + Number(record.amount || 0), 0);
+
+ const renderCostTables = (scenarioId) => {
+ if (
+ !capexTableBody ||
+ !opexTableBody ||
+ !capexEmpty ||
+ !opexEmpty ||
+ !capexTotal ||
+ !opexTotal
+ ) {
+ return;
+ }
+
+ 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 = `
+ ${formatAmount(record.amount)} |
+ ${record.description || "—"} |
+ `;
+ 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 = `
+ ${formatAmount(record.amount)} |
+ ${record.description || "—"} |
+ `;
+ opexTableBody.appendChild(row);
+ });
+ }
+
+ capexTotal.textContent = formatAmount(sumAmount(capexRecords));
+ opexTotal.textContent = formatAmount(sumAmount(opexRecords));
+ };
+
+ const toggleCostView = (show) => {
+ if (
+ !costsEmptyState ||
+ !costsDataWrapper ||
+ !capexTableBody ||
+ !opexTableBody
+ ) {
+ return;
+ }
+
+ if (show) {
+ costsEmptyState.classList.add("hidden");
+ costsDataWrapper.classList.remove("hidden");
+ } else {
+ costsEmptyState.classList.remove("hidden");
+ costsDataWrapper.classList.add("hidden");
+ capexTableBody.innerHTML = "";
+ opexTableBody.innerHTML = "";
+ if (capexTotal) {
+ capexTotal.textContent = "—";
+ }
+ if (opexTotal) {
+ opexTotal.textContent = "—";
+ }
+ if (capexEmpty) {
+ capexEmpty.classList.add("hidden");
+ }
+ if (opexEmpty) {
+ opexEmpty.classList.add("hidden");
+ }
+ }
+ };
+
+ const 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);
+ });
+ }
+
+ const submitCostEntry = async (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);
+ }
+});
diff --git a/static/js/dashboard.js b/static/js/dashboard.js
new file mode 100644
index 0000000..5cec954
--- /dev/null
+++ b/static/js/dashboard.js
@@ -0,0 +1,289 @@
+(() => {
+ 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;
+
+ const 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");
+ };
+
+ const 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 = `
+ ${metric.label}
+ ${metric.value}
+ `;
+ summaryContainer.appendChild(card);
+ });
+ summaryEmpty.hidden = metrics.length > 0;
+ };
+
+ const 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 = `
+ ${row.scenario_name} |
+ ${row.parameter_display} |
+ ${row.equipment_display} |
+ ${row.capex_display} |
+ ${row.opex_display} |
+ ${row.production_display} |
+ ${row.consumption_display} |
+ ${row.maintenance_display} |
+ ${row.iterations_display} |
+ ${row.simulation_mean_display} |
+ `;
+ scenarioTableBody.appendChild(tr);
+ });
+ scenarioEmpty.hidden = rows.length > 0;
+ };
+
+ const renderOverallMetrics = () => {
+ if (!overallMetricsList || !overallMetricsEmpty) {
+ return;
+ }
+ overallMetricsList.innerHTML = "";
+ const items = Array.isArray(state.overall_report_metrics)
+ ? state.overall_report_metrics
+ : [];
+ items.forEach((item) => {
+ const li = document.createElement("li");
+ li.className = "metric-list-item";
+ li.textContent = `${item.label}: ${item.value}`;
+ overallMetricsList.appendChild(li);
+ });
+ overallMetricsEmpty.hidden = items.length > 0;
+ };
+
+ const renderRecentSimulations = () => {
+ if (!recentList || !recentEmpty) {
+ return;
+ }
+ recentList.innerHTML = "";
+ const runs = Array.isArray(state.recent_simulations)
+ ? state.recent_simulations
+ : [];
+ runs.forEach((run) => {
+ const item = document.createElement("li");
+ item.className = "metric-list-item";
+ item.textContent = `${run.scenario_name} · ${run.iterations_display} iterations · ${run.mean_display}`;
+ recentList.appendChild(item);
+ });
+ recentEmpty.hidden = runs.length > 0;
+ };
+
+ const renderMaintenanceReminders = () => {
+ 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 = `
+ ${item.equipment_name} · ${item.scenario_name}
+ ${item.date_display} · ${item.cost_display} · ${item.description}
+ `;
+ maintenanceList.appendChild(li);
+ });
+ maintenanceEmpty.hidden = items.length > 0;
+ };
+
+ const buildChartConfig = (dataset, overrides = {}) => ({
+ type: dataset.type || "bar",
+ data: {
+ labels: dataset.labels || [],
+ datasets: dataset.datasets || [],
+ },
+ options: Object.assign(
+ {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { position: "top" },
+ tooltip: { enabled: true },
+ },
+ scales: {
+ x: { stacked: dataset.stacked ?? false },
+ y: { stacked: dataset.stacked ?? false, beginAtZero: true },
+ },
+ },
+ overrides.options || {}
+ ),
+ });
+
+ const renderCharts = () => {
+ if (costChartInstance) {
+ costChartInstance.destroy();
+ }
+ if (activityChartInstance) {
+ activityChartInstance.destroy();
+ }
+
+ const costData = state.scenario_cost_chart || {};
+ const activityData = state.scenario_activity_chart || {};
+
+ if (costChartCanvas && state.cost_chart_has_data) {
+ costChartInstance = new Chart(
+ costChartCanvas,
+ buildChartConfig(costData, {
+ options: {
+ scales: {
+ y: {
+ beginAtZero: true,
+ ticks: {
+ callback: (value) =>
+ typeof value === "number"
+ ? value.toLocaleString(undefined, {
+ maximumFractionDigits: 0,
+ })
+ : value,
+ },
+ },
+ },
+ },
+ })
+ );
+ if (costChartEmpty) {
+ costChartEmpty.hidden = true;
+ }
+ costChartCanvas.classList.remove("hidden");
+ } else if (costChartEmpty && costChartCanvas) {
+ costChartEmpty.hidden = false;
+ costChartCanvas.classList.add("hidden");
+ }
+
+ if (activityChartCanvas && state.activity_chart_has_data) {
+ activityChartInstance = new Chart(
+ activityChartCanvas,
+ buildChartConfig(activityData, {
+ options: {
+ scales: {
+ y: {
+ beginAtZero: true,
+ ticks: {
+ callback: (value) =>
+ typeof value === "number"
+ ? value.toLocaleString(undefined, {
+ maximumFractionDigits: 0,
+ })
+ : value,
+ },
+ },
+ },
+ },
+ })
+ );
+ if (activityChartEmpty) {
+ activityChartEmpty.hidden = true;
+ }
+ activityChartCanvas.classList.remove("hidden");
+ } else if (activityChartEmpty && activityChartCanvas) {
+ activityChartEmpty.hidden = false;
+ activityChartCanvas.classList.add("hidden");
+ }
+ };
+
+ const renderView = () => {
+ renderSummaryMetrics();
+ renderScenarioTable();
+ renderOverallMetrics();
+ renderRecentSimulations();
+ renderMaintenanceReminders();
+ renderCharts();
+ };
+
+ const refreshDashboard = async () => {
+ setStatus("Refreshing dashboard…", "success");
+ if (refreshButton) {
+ refreshButton.classList.add("is-loading");
+ }
+
+ try {
+ const response = await fetch("/ui/dashboard/data", {
+ headers: { "X-Requested-With": "XMLHttpRequest" },
+ });
+
+ if (!response.ok) {
+ throw new Error("Unable to refresh dashboard data.");
+ }
+
+ const payload = await response.json();
+ state = payload || {};
+ renderView();
+ setStatus("Dashboard updated.", "success");
+ } catch (error) {
+ console.error(error);
+ setStatus(error.message || "Failed to refresh dashboard.", "error");
+ } finally {
+ if (refreshButton) {
+ refreshButton.classList.remove("is-loading");
+ }
+ }
+ };
+
+ renderView();
+
+ if (refreshButton) {
+ refreshButton.addEventListener("click", refreshDashboard);
+ }
+})();
diff --git a/static/js/equipment.js b/static/js/equipment.js
new file mode 100644
index 0000000..cf2c56d
--- /dev/null
+++ b/static/js/equipment.js
@@ -0,0 +1,145 @@
+document.addEventListener("DOMContentLoaded", () => {
+ const dataElement = document.getElementById("equipment-data");
+ let equipmentByScenario = {};
+
+ if (dataElement) {
+ try {
+ const parsed = JSON.parse(dataElement.textContent || "{}");
+ if (parsed && typeof parsed === "object") {
+ if (parsed.equipment && typeof parsed.equipment === "object") {
+ equipmentByScenario = parsed.equipment;
+ }
+ }
+ } catch (error) {
+ console.error("Unable to parse equipment data", error);
+ }
+ }
+
+ 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");
+
+ const showFeedback = (message, type = "success") => {
+ if (!feedbackEl) {
+ return;
+ }
+ feedbackEl.textContent = message;
+ feedbackEl.classList.remove("hidden", "success", "error");
+ feedbackEl.classList.add(type);
+ };
+
+ const hideFeedback = () => {
+ if (!feedbackEl) {
+ return;
+ }
+ feedbackEl.classList.add("hidden");
+ feedbackEl.textContent = "";
+ };
+
+ const renderEquipmentRows = (scenarioId) => {
+ if (!tableBody || !tableWrapper || !emptyState) {
+ return;
+ }
+
+ 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 = `
+ ${record.name || "—"} |
+ ${record.description || "—"} |
+ `;
+ tableBody.appendChild(row);
+ });
+ };
+
+ if (filterSelect) {
+ filterSelect.addEventListener("change", (event) => {
+ const value = event.target.value;
+ if (!value) {
+ if (emptyState && tableWrapper && tableBody) {
+ emptyState.textContent =
+ "Choose a scenario to review the equipment list.";
+ emptyState.classList.remove("hidden");
+ tableWrapper.classList.add("hidden");
+ tableBody.innerHTML = "";
+ }
+ return;
+ }
+ renderEquipmentRows(value);
+ });
+ }
+
+ const submitEquipment = async (event) => {
+ event.preventDefault();
+ hideFeedback();
+
+ if (!form) {
+ return;
+ }
+
+ 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);
+ }
+});
diff --git a/static/js/maintenance.js b/static/js/maintenance.js
new file mode 100644
index 0000000..9ec5f4a
--- /dev/null
+++ b/static/js/maintenance.js
@@ -0,0 +1,243 @@
+document.addEventListener("DOMContentLoaded", () => {
+ const dataElement = document.getElementById("maintenance-data");
+ let equipmentByScenario = {};
+ let maintenanceByScenario = {};
+
+ if (dataElement) {
+ try {
+ const parsed = JSON.parse(dataElement.textContent || "{}");
+ if (parsed && typeof parsed === "object") {
+ if (parsed.equipment && typeof parsed.equipment === "object") {
+ equipmentByScenario = parsed.equipment;
+ }
+ if (parsed.maintenance && typeof parsed.maintenance === "object") {
+ maintenanceByScenario = parsed.maintenance;
+ }
+ }
+ } catch (error) {
+ console.error("Unable to parse maintenance data", error);
+ }
+ }
+
+ 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"
+ );
+
+ const showFeedback = (message, type = "success") => {
+ if (!feedbackEl) {
+ return;
+ }
+ feedbackEl.textContent = message;
+ feedbackEl.classList.remove("hidden", "success", "error");
+ feedbackEl.classList.add(type);
+ };
+
+ const hideFeedback = () => {
+ if (!feedbackEl) {
+ return;
+ }
+ feedbackEl.classList.add("hidden");
+ feedbackEl.textContent = "";
+ };
+
+ const formatCost = (value) =>
+ Number(value).toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+
+ const formatDate = (value) => {
+ if (!value) {
+ return "—";
+ }
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ return value;
+ }
+ return parsed.toLocaleDateString();
+ };
+
+ const renderMaintenanceRows = (scenarioId) => {
+ if (!tableBody || !tableWrapper || !emptyState) {
+ return;
+ }
+
+ 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 = `
+ ${formatDate(record.maintenance_date)} |
+ ${record.equipment_name || "—"} |
+ ${formatCost(record.cost)} |
+ ${record.description || "—"} |
+ `;
+ tableBody.appendChild(row);
+ });
+ };
+
+ const populateEquipmentOptions = (scenarioId) => {
+ if (!equipmentSelect) {
+ return;
+ }
+
+ equipmentSelect.innerHTML =
+ '';
+ 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) {
+ if (emptyState && tableWrapper && tableBody) {
+ 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);
+ });
+ }
+
+ const submitMaintenance = async (event) => {
+ event.preventDefault();
+ hideFeedback();
+
+ if (!form) {
+ return;
+ }
+
+ 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);
+ }
+});
diff --git a/static/js/parameters.js b/static/js/parameters.js
new file mode 100644
index 0000000..b96d207
--- /dev/null
+++ b/static/js/parameters.js
@@ -0,0 +1,124 @@
+document.addEventListener("DOMContentLoaded", () => {
+ const dataElement = document.getElementById("parameters-data");
+ let parametersByScenario = {};
+
+ if (dataElement) {
+ try {
+ const parsed = JSON.parse(dataElement.textContent || "{}");
+ if (parsed && typeof parsed === "object") {
+ parametersByScenario = parsed;
+ }
+ } catch (error) {
+ console.error("Unable to parse parameter data", error);
+ }
+ }
+
+ 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 =
+ 'No parameters recorded for this scenario yet. | ';
+ tableBody.appendChild(emptyRow);
+ return;
+ }
+ rows.forEach((row) => {
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ ${row.name} |
+ ${row.value} |
+ ${row.distribution_type ?? "—"} |
+ ${
+ row.distribution_parameters
+ ? JSON.stringify(row.distribution_parameters)
+ : "—"
+ } |
+ `;
+ 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");
+ });
+});
diff --git a/static/js/production.js b/static/js/production.js
new file mode 100644
index 0000000..3160bd8
--- /dev/null
+++ b/static/js/production.js
@@ -0,0 +1,157 @@
+document.addEventListener("DOMContentLoaded", () => {
+ const dataElement = document.getElementById("production-data");
+ let data = { scenarios: [], production: {} };
+
+ if (dataElement) {
+ try {
+ const parsed = JSON.parse(dataElement.textContent || "{}");
+ if (parsed && typeof parsed === "object") {
+ data = {
+ scenarios: Array.isArray(parsed.scenarios) ? parsed.scenarios : [],
+ production:
+ parsed.production && typeof parsed.production === "object"
+ ? parsed.production
+ : {},
+ };
+ }
+ } catch (error) {
+ console.error("Unable to parse production data", error);
+ }
+ }
+
+ const productionByScenario = data.production;
+ 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");
+
+ const showFeedback = (message, type = "success") => {
+ if (!feedbackEl) {
+ return;
+ }
+ feedbackEl.textContent = message;
+ feedbackEl.classList.remove("hidden", "success", "error");
+ feedbackEl.classList.add(type);
+ };
+
+ const hideFeedback = () => {
+ if (!feedbackEl) {
+ return;
+ }
+ feedbackEl.classList.add("hidden");
+ feedbackEl.textContent = "";
+ };
+
+ const formatAmount = (value) =>
+ Number(value).toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+
+ const renderProductionRows = (scenarioId) => {
+ if (!tableBody || !tableWrapper || !emptyState) {
+ return;
+ }
+
+ 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 = `
+ ${formatAmount(record.amount)} |
+ ${record.description || "—"} |
+ `;
+ tableBody.appendChild(row);
+ });
+ };
+
+ if (filterSelect) {
+ filterSelect.addEventListener("change", (event) => {
+ const value = event.target.value;
+ if (!value) {
+ if (emptyState && tableWrapper && tableBody) {
+ emptyState.textContent =
+ "Choose a scenario to review its production output.";
+ emptyState.classList.remove("hidden");
+ tableWrapper.classList.add("hidden");
+ tableBody.innerHTML = "";
+ }
+ return;
+ }
+ renderProductionRows(value);
+ });
+ }
+
+ const submitProduction = async (event) => {
+ event.preventDefault();
+ hideFeedback();
+
+ if (!form) {
+ return;
+ }
+
+ 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);
+ }
+});
diff --git a/static/js/reporting.js b/static/js/reporting.js
new file mode 100644
index 0000000..3ca2f64
--- /dev/null
+++ b/static/js/reporting.js
@@ -0,0 +1,149 @@
+document.addEventListener("DOMContentLoaded", () => {
+ const dataElement = document.getElementById("reporting-data");
+ let reportingSummaries = [];
+
+ if (dataElement) {
+ try {
+ const parsed = JSON.parse(dataElement.textContent || "[]");
+ if (Array.isArray(parsed)) {
+ reportingSummaries = parsed;
+ }
+ } catch (error) {
+ console.error("Unable to parse reporting data", error);
+ }
+ }
+
+ 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");
+
+ const formatNumber = (value, decimals = 2) => {
+ if (value === null || value === undefined || Number.isNaN(Number(value))) {
+ return "—";
+ }
+ return Number(value).toLocaleString(undefined, {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ });
+ };
+
+ const showFeedback = (message, type = "success") => {
+ if (!feedbackEl) {
+ return;
+ }
+ feedbackEl.textContent = message;
+ feedbackEl.classList.remove("hidden", "success", "error");
+ feedbackEl.classList.add(type);
+ };
+
+ const hideFeedback = () => {
+ if (!feedbackEl) {
+ return;
+ }
+ feedbackEl.classList.add("hidden");
+ feedbackEl.textContent = "";
+ };
+
+ const 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);
+ });
+ };
+
+ const refreshMetrics = async () => {
+ 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);
+ }
+});
diff --git a/static/js/scenario-form.js b/static/js/scenario-form.js
new file mode 100644
index 0000000..7a8bd86
--- /dev/null
+++ b/static/js/scenario-form.js
@@ -0,0 +1,69 @@
+document.addEventListener("DOMContentLoaded", () => {
+ const form = document.getElementById("scenario-form");
+ if (!form) {
+ return;
+ }
+
+ const nameInput = /** @type {HTMLInputElement | null} */ (
+ document.getElementById("name")
+ );
+ const descriptionInput = /** @type {HTMLInputElement | null} */ (
+ 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();
+
+ if (!nameInput || !descriptionInput) {
+ return;
+ }
+
+ 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 = `
+ ${data.name} |
+ ${data.description ?? "—"} |
+ `;
+
+ if (emptyState) {
+ emptyState.remove();
+ }
+
+ if (table) {
+ table.classList.remove("hidden");
+ table.removeAttribute("aria-hidden");
+ }
+
+ if (tableBody) {
+ tableBody.appendChild(row);
+ }
+
+ form.reset();
+ nameInput.focus();
+ });
+});
diff --git a/static/js/simulations.js b/static/js/simulations.js
new file mode 100644
index 0000000..9e9c8d6
--- /dev/null
+++ b/static/js/simulations.js
@@ -0,0 +1,354 @@
+document.addEventListener("DOMContentLoaded", () => {
+ const dataElement = document.getElementById("simulations-data");
+ let simulationScenarios = [];
+ let initialRuns = [];
+
+ if (dataElement) {
+ try {
+ const parsed = JSON.parse(dataElement.textContent || "{}");
+ if (parsed && typeof parsed === "object") {
+ if (Array.isArray(parsed.scenarios)) {
+ simulationScenarios = parsed.scenarios;
+ }
+ if (Array.isArray(parsed.runs)) {
+ initialRuns = parsed.runs;
+ }
+ }
+ } catch (error) {
+ console.error("Unable to parse simulations data", error);
+ }
+ }
+
+ 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);
+
+ const getScenarioName = (id) => {
+ const match = simulationScenarios.find(
+ (scenario) => String(scenario.id) === String(id)
+ );
+ return match ? match.name : `Scenario ${id}`;
+ };
+
+ const formatNumber = (value, decimals = 2) => {
+ if (value === null || value === undefined || Number.isNaN(Number(value))) {
+ return "—";
+ }
+ return Number(value).toLocaleString(undefined, {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ });
+ };
+
+ const showFeedback = (element, message, type = "success") => {
+ if (!element) {
+ return;
+ }
+ element.textContent = message;
+ element.classList.remove("hidden", "success", "error");
+ element.classList.add(type);
+ };
+
+ const hideFeedback = (element) => {
+ if (!element) {
+ return;
+ }
+ element.classList.add("hidden");
+ element.textContent = "";
+ };
+
+ const 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
+ : [],
+ };
+ });
+ };
+
+ const 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 = `
+ ${scenario.name} |
+ ${iterations || 0} |
+ ${iterations ? formatNumber(meanValue) : "—"} |
+ `;
+ overviewBody.appendChild(row);
+ });
+ };
+
+ const 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[String(scenarioId)];
+ const summary = run ? run.summary : null;
+ const samples = run ? run.sample_results || [] : [];
+
+ if (!summary) {
+ if (summaryWrapper) {
+ summaryWrapper.classList.add("hidden");
+ }
+ if (summaryEmpty) {
+ summaryEmpty.classList.remove("hidden");
+ }
+ } else {
+ if (summaryWrapper) {
+ summaryWrapper.classList.remove("hidden");
+ }
+ if (summaryEmpty) {
+ summaryEmpty.classList.add("hidden");
+ }
+
+ if (summaryBody) {
+ summaryBody.innerHTML = "";
+ SUMMARY_FIELDS.forEach((field) => {
+ const row = document.createElement("tr");
+ row.innerHTML = `
+ ${field.label} |
+ ${formatNumber(summary[field.key], field.decimals)} |
+ `;
+ summaryBody.appendChild(row);
+ });
+ }
+ }
+
+ if (!samples.length) {
+ if (resultsWrapper) {
+ resultsWrapper.classList.add("hidden");
+ }
+ if (resultsEmpty) {
+ resultsEmpty.classList.remove("hidden");
+ }
+ } else {
+ if (resultsWrapper) {
+ resultsWrapper.classList.remove("hidden");
+ }
+ if (resultsEmpty) {
+ resultsEmpty.classList.add("hidden");
+ }
+
+ if (resultsBody) {
+ resultsBody.innerHTML = "";
+ samples.slice(0, SAMPLE_RESULT_LIMIT).forEach((item, index) => {
+ const row = document.createElement("tr");
+ row.innerHTML = `
+ ${index + 1} |
+ ${formatNumber(item)} |
+ `;
+ resultsBody.appendChild(row);
+ });
+ }
+ }
+ };
+
+ const runSimulation = async (event) => {
+ event.preventDefault();
+ hideFeedback(simulationFeedback);
+
+ if (!simulationForm) {
+ return;
+ }
+
+ const formData = new FormData(simulationForm);
+ const scenarioId = formData.get("scenario_id");
+ const payload = {
+ scenario_id: scenarioId ? Number(scenarioId) : null,
+ iterations: Number(formData.get("iterations")),
+ seed: formData.get("seed") ? Number(formData.get("seed")) : null,
+ };
+
+ if (!payload.scenario_id) {
+ showFeedback(
+ simulationFeedback,
+ "Select a scenario before running a simulation.",
+ "error"
+ );
+ return;
+ }
+
+ try {
+ const response = await fetch("/api/simulations/", {
+ 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 run simulation.");
+ }
+
+ const result = await response.json();
+ const mapKey = String(result.scenario_id);
+ const summary =
+ result.summary && typeof result.summary === "object"
+ ? result.summary
+ : null;
+ const iterations =
+ summary && typeof summary.count === "number"
+ ? summary.count
+ : payload.iterations || 0;
+
+ simulationRunsMap[mapKey] = {
+ scenario_id: result.scenario_id,
+ scenario_name: getScenarioName(mapKey),
+ iterations,
+ summary,
+ sample_results: Array.isArray(result.sample_results)
+ ? result.sample_results
+ : [],
+ };
+
+ renderOverviewTable();
+ renderScenarioDetails(mapKey);
+
+ if (filterSelect) {
+ filterSelect.value = mapKey;
+ }
+ if (formScenarioSelect) {
+ formScenarioSelect.value = mapKey;
+ }
+
+ simulationForm.reset();
+ showFeedback(simulationFeedback, "Simulation completed.", "success");
+ } catch (error) {
+ showFeedback(
+ simulationFeedback,
+ error.message || "An unexpected error occurred.",
+ "error"
+ );
+ }
+ };
+
+ initializeRunsMap();
+ renderOverviewTable();
+
+ if (filterSelect) {
+ filterSelect.addEventListener("change", (event) => {
+ const value = event.target.value;
+ renderScenarioDetails(value);
+ });
+ }
+
+ if (formScenarioSelect) {
+ formScenarioSelect.addEventListener("change", (event) => {
+ const value = event.target.value;
+ if (filterSelect) {
+ filterSelect.value = value;
+ }
+ renderScenarioDetails(value);
+ });
+ }
+
+ if (simulationForm) {
+ simulationForm.addEventListener("submit", runSimulation);
+ }
+
+ if (filterSelect && filterSelect.value) {
+ renderScenarioDetails(filterSelect.value);
+ }
+});
diff --git a/templates/Dashboard.html b/templates/Dashboard.html
index dcd518a..ef2dfcb 100644
--- a/templates/Dashboard.html
+++ b/templates/Dashboard.html
@@ -84,493 +84,11 @@ block content %}
endif
%}
>
-
- Add production or consumption records to display this chart.
-
-
-
-
-
-
-
-
- | Scenario |
- Parameters |
- Equipment |
- CAPEX |
- OPEX |
- Production |
- Consumption |
- Maintenance |
- Iterations |
- Simulation Mean |
-
-
-
- {% for row in scenario_rows %}
-
- | {{ row.scenario_name }} |
- {{ row.parameter_display }} |
- {{ row.equipment_display }} |
- {{ row.capex_display }} |
- {{ row.opex_display }} |
- {{ row.production_display }} |
- {{ row.consumption_display }} |
- {{ row.maintenance_display }} |
- {{ row.iterations_display }} |
- {{ row.simulation_mean_display }} |
-
- {% endfor %}
-
-
-
-
- 0 %} hidden{% endif %}> Create scenarios and populate domain data to see the
- snapshot overview.
-
-
-
-
-
-
-
- {% for metric in overall_report_metrics %}
- -
- {{ metric.label }}
- {{ metric.value }}
-
- {% endfor %}
-
-
- Run a simulation to surface aggregate reporting metrics.
-
-
-
-
-
-
- {% for run in recent_simulations %}
- -
- {{ run.scenario_name }}
- Iterations: {{ run.iterations_display }} · Mean: {{ run.mean_display
- }} · P95: {{ run.p95_display }}
-
- {% endfor %}
-
-
- 0 %} hidden{% endif %}> Trigger simulations to populate recent run
- statistics.
-
-
-
-
-
-
-
- {% for item in upcoming_maintenance %}
- -
- {{ item.equipment_name }} · {{ item.scenario_name }}
- {{ item.date_display }} · {{ item.cost_display }} · {{ item.description
- }}
-
- {% endfor %}
-
-
- 0 %} hidden{% endif %}> Schedule maintenance activities to track upcoming
- events.
-
-
{% endblock %} {% block scripts %} {{ super() }}
-
-
+
{% endblock %}
diff --git a/templates/ParameterInput.html b/templates/ParameterInput.html
index 245c726..a074b1f 100644
--- a/templates/ParameterInput.html
+++ b/templates/ParameterInput.html
@@ -44,125 +44,7 @@ endblock %} {% block content %}
{% endblock %} {% block scripts %} {{ super() }}
-
+
{% endblock %}
diff --git a/templates/ScenarioForm.html b/templates/ScenarioForm.html
index e659749..0fcf51b 100644
--- a/templates/ScenarioForm.html
+++ b/templates/ScenarioForm.html
@@ -48,67 +48,5 @@ endblock %} {% block content %}
{% endblock %} {% block scripts %} {{ super() }}
-
+
{% endblock %}
diff --git a/templates/consumption.html b/templates/consumption.html
index e1bd037..faba55f 100644
--- a/templates/consumption.html
+++ b/templates/consumption.html
@@ -71,132 +71,9 @@
{% endif %}
-
+
{% endblock %}
diff --git a/templates/costs.html b/templates/costs.html
index eb47b9f..eebe643 100644
--- a/templates/costs.html
+++ b/templates/costs.html
@@ -149,200 +149,9 @@ block content %}
{% endif %}
-
+
{% endblock %}
diff --git a/templates/equipment.html b/templates/equipment.html
index 4a05c79..25af56b 100644
--- a/templates/equipment.html
+++ b/templates/equipment.html
@@ -66,125 +66,9 @@ block content %}
{% endif %}
-
+
{% endblock %}
diff --git a/templates/maintenance.html b/templates/maintenance.html
index 9e73a73..9f30ce7 100644
--- a/templates/maintenance.html
+++ b/templates/maintenance.html
@@ -100,209 +100,9 @@
{% endif %}
-
+
{% endblock %}
diff --git a/templates/production.html b/templates/production.html
index 5d12569..70e0f18 100644
--- a/templates/production.html
+++ b/templates/production.html
@@ -73,132 +73,9 @@
{% endif %}
-
+
{% endblock %}
diff --git a/templates/reporting.html b/templates/reporting.html
index 6916d56..aa9a635 100644
--- a/templates/reporting.html
+++ b/templates/reporting.html
@@ -33,138 +33,9 @@ block content %}
-
+
{% endblock %}
diff --git a/templates/simulations.html b/templates/simulations.html
index 529467a..5e8051f 100644
--- a/templates/simulations.html
+++ b/templates/simulations.html
@@ -31,417 +31,11 @@
Mean Result |
-
-
-
-
- Create a scenario to review simulation history.
-
+
-
- Select a scenario to review simulation outputs.
-
-
-
-
Summary Metrics
-
-
-
- | Metric |
- Value |
-
-
-
-
-
-
- No simulations have been run for this scenario yet.
-
-
-
-
Sample Results
-
-
-
- | Iteration |
- Result |
-
-
-
-
-
-
- No sample results available for this scenario.
-
-
-
-
- Run Simulation
- {% if simulation_scenarios %}
-
-
- {% else %}
- Create at least one scenario to run simulations.
- {% endif %}
-
-
-
-{% endblock %}
+ {% endblock %} {% block scripts %} {{ super() }}
+
+
+ {% endblock %}