From 18f4ae7278e882c4ebc32b1c803d0e676736f669 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Tue, 21 Oct 2025 07:20:02 +0200 Subject: [PATCH] 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. --- README.md | 1 + static/js/consumption.js | 156 +++++++++++ static/js/costs.js | 240 +++++++++++++++++ static/js/dashboard.js | 289 ++++++++++++++++++++ static/js/equipment.js | 145 ++++++++++ static/js/maintenance.js | 243 +++++++++++++++++ static/js/parameters.js | 124 +++++++++ static/js/production.js | 157 +++++++++++ static/js/reporting.js | 149 +++++++++++ static/js/scenario-form.js | 69 +++++ static/js/simulations.js | 354 +++++++++++++++++++++++++ templates/Dashboard.html | 486 +--------------------------------- templates/ParameterInput.html | 122 +-------- templates/ScenarioForm.html | 64 +---- templates/consumption.html | 131 +-------- templates/costs.html | 199 +------------- templates/equipment.html | 124 +-------- templates/maintenance.html | 208 +-------------- templates/production.html | 131 +-------- templates/reporting.html | 137 +--------- templates/simulations.html | 420 +---------------------------- 21 files changed, 1963 insertions(+), 1986 deletions(-) create mode 100644 static/js/consumption.js create mode 100644 static/js/costs.js create mode 100644 static/js/dashboard.js create mode 100644 static/js/equipment.js create mode 100644 static/js/maintenance.js create mode 100644 static/js/parameters.js create mode 100644 static/js/production.js create mode 100644 static/js/reporting.js create mode 100644 static/js/scenario-form.js create mode 100644 static/js/simulations.js 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 Snapshot

-

- Operational and financial highlights per scenario -

-
-
-
- - - - - - - - - - - - - - - - - {% for row in scenario_rows %} - - - - - - - - - - - - - {% endfor %} - -
ScenarioParametersEquipmentCAPEXOPEXProductionConsumptionMaintenanceIterationsSimulation Mean
{{ 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 }}
-
-

- 0 %} hidden{% endif %}> Create scenarios and populate domain data to see the - snapshot overview. -

-
- -
-
-
-

Overall Simulation Metrics

-
-
    - {% for metric in overall_report_metrics %} -
  • - {{ metric.label }} - {{ metric.value }} -
  • - {% endfor %} -
-

- Run a simulation to surface aggregate reporting metrics. -

-
- -
-
-

Recent Simulation Runs

-
-
    - {% 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. -

-
-
- -
-
-

Upcoming Maintenance

-
- -

- 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 - - - - + -
- Select a scenario to review simulation outputs. -
- - - - - - - - -
-

Run Simulation

- {% if simulation_scenarios %} -
- - - - -
- - {% else %} -

Create at least one scenario to run simulations.

- {% endif %} -
- - -{% endblock %} + {% endblock %} {% block scripts %} {{ super() }} + + + {% endblock %}