- Updated equipment.html to include scenario filtering, equipment addition form, and dynamic equipment listing. - Enhanced maintenance.html with scenario filtering, maintenance entry form, and dynamic equipment selection. - Improved production.html to allow scenario filtering and production output entry. - Revamped reporting.html to display scenario KPI summaries with refresh functionality. - Expanded simulations.html to support scenario selection, simulation run history, and detailed results display. - Refactored base_header.html for improved navigation structure and active link highlighting.
577 lines
16 KiB
HTML
577 lines
16 KiB
HTML
{% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {%
|
|
block content %}
|
|
<div class="dashboard-header">
|
|
<div>
|
|
<h2>Operations Overview</h2>
|
|
<p class="dashboard-subtitle">
|
|
Unified insight across scenarios, costs, production, maintenance, and
|
|
simulations.
|
|
</p>
|
|
</div>
|
|
<div class="dashboard-actions">
|
|
<button id="refresh-dashboard" type="button" class="btn primary">
|
|
Refresh Dashboard
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p id="dashboard-status" class="feedback" hidden></p>
|
|
|
|
<section>
|
|
<div id="summary-metrics" class="dashboard-metrics-grid">
|
|
{% for metric in summary_metrics %}
|
|
<article class="metric-card">
|
|
<span class="metric-label">{{ metric.label }}</span>
|
|
<span class="metric-value">{{ metric.value }}</span>
|
|
</article>
|
|
{% endfor %}
|
|
</div>
|
|
<p id="summary-empty" class="empty-state" {% if summary_metrics|length>
|
|
0 %} hidden{% endif %}> Add project inputs to populate summary metrics.
|
|
</p>
|
|
</section>
|
|
|
|
<section class="dashboard-charts">
|
|
<article class="panel chart-card">
|
|
<header class="panel-header">
|
|
<div>
|
|
<h3>Scenario Cost Mix</h3>
|
|
<p class="chart-subtitle">CAPEX vs OPEX totals per scenario</p>
|
|
</div>
|
|
</header>
|
|
<canvas
|
|
id="cost-chart"
|
|
height="220"
|
|
{%
|
|
if
|
|
not
|
|
cost_chart_has_data
|
|
%}
|
|
hidden{%
|
|
endif
|
|
%}
|
|
></canvas>
|
|
<p
|
|
id="cost-chart-empty"
|
|
class="empty-state"
|
|
{%
|
|
if
|
|
cost_chart_has_data
|
|
%}
|
|
hidden{%
|
|
endif
|
|
%}
|
|
>
|
|
Add CAPEX or OPEX entries to display this chart.
|
|
</p>
|
|
</article>
|
|
<article class="panel chart-card">
|
|
<header class="panel-header">
|
|
<div>
|
|
<h3>Production vs Consumption</h3>
|
|
<p class="chart-subtitle">Throughput comparison by scenario</p>
|
|
</div>
|
|
</header>
|
|
<canvas
|
|
id="activity-chart"
|
|
height="220"
|
|
{%
|
|
if
|
|
not
|
|
activity_chart_has_data
|
|
%}
|
|
hidden{%
|
|
endif
|
|
%}
|
|
></canvas>
|
|
<p
|
|
id="activity-chart-empty"
|
|
class="empty-state"
|
|
{%
|
|
if
|
|
activity_chart_has_data
|
|
%}
|
|
hidden{%
|
|
endif
|
|
%}
|
|
>
|
|
Add production or consumption records to display this chart.
|
|
</p>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="panel dashboard-panel">
|
|
<header class="panel-header">
|
|
<div>
|
|
<h3>Scenario Snapshot</h3>
|
|
<p class="chart-subtitle">
|
|
Operational and financial highlights per scenario
|
|
</p>
|
|
</div>
|
|
</header>
|
|
<div class="table-container">
|
|
<table id="scenario-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Scenario</th>
|
|
<th>Parameters</th>
|
|
<th>Equipment</th>
|
|
<th>CAPEX</th>
|
|
<th>OPEX</th>
|
|
<th>Production</th>
|
|
<th>Consumption</th>
|
|
<th>Maintenance</th>
|
|
<th>Iterations</th>
|
|
<th>Simulation Mean</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for row in scenario_rows %}
|
|
<tr>
|
|
<td>{{ row.scenario_name }}</td>
|
|
<td>{{ row.parameter_display }}</td>
|
|
<td>{{ row.equipment_display }}</td>
|
|
<td>{{ row.capex_display }}</td>
|
|
<td>{{ row.opex_display }}</td>
|
|
<td>{{ row.production_display }}</td>
|
|
<td>{{ row.consumption_display }}</td>
|
|
<td>{{ row.maintenance_display }}</td>
|
|
<td>{{ row.iterations_display }}</td>
|
|
<td>{{ row.simulation_mean_display }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p id="scenario-table-empty" class="empty-state" {% if scenario_rows|length>
|
|
0 %} hidden{% endif %}> Create scenarios and populate domain data to see the
|
|
snapshot overview.
|
|
</p>
|
|
</section>
|
|
|
|
<div class="dashboard-columns">
|
|
<section class="panel dashboard-panel">
|
|
<header class="panel-header">
|
|
<h3>Overall Simulation Metrics</h3>
|
|
</header>
|
|
<ul id="overall-metrics" class="metric-list">
|
|
{% for metric in overall_report_metrics %}
|
|
<li>
|
|
<span class="metric-label">{{ metric.label }}</span>
|
|
<span class="metric-value">{{ metric.value }}</span>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
<p
|
|
id="overall-metrics-empty"
|
|
class="empty-state"
|
|
{%
|
|
if
|
|
report_available
|
|
%}
|
|
hidden{%
|
|
endif
|
|
%}
|
|
>
|
|
Run a simulation to surface aggregate reporting metrics.
|
|
</p>
|
|
</section>
|
|
|
|
<section class="panel dashboard-panel">
|
|
<header class="panel-header">
|
|
<h3>Recent Simulation Runs</h3>
|
|
</header>
|
|
<ul id="recent-simulations" class="striped-list">
|
|
{% for run in recent_simulations %}
|
|
<li>
|
|
<span class="list-title">{{ run.scenario_name }}</span>
|
|
<span class="list-detail"
|
|
>Iterations: {{ run.iterations_display }} · Mean: {{ run.mean_display
|
|
}} · P95: {{ run.p95_display }}</span
|
|
>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
<p
|
|
id="recent-simulations-empty"
|
|
class="empty-state"
|
|
{%
|
|
if
|
|
recent_simulations|length
|
|
>
|
|
0 %} hidden{% endif %}> Trigger simulations to populate recent run
|
|
statistics.
|
|
</p>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="panel dashboard-panel">
|
|
<header class="panel-header">
|
|
<h3>Upcoming Maintenance</h3>
|
|
</header>
|
|
<ul id="upcoming-maintenance" class="striped-list">
|
|
{% for item in upcoming_maintenance %}
|
|
<li>
|
|
<span class="list-title"
|
|
>{{ item.equipment_name }} · {{ item.scenario_name }}</span
|
|
>
|
|
<span class="list-detail"
|
|
>{{ item.date_display }} · {{ item.cost_display }} · {{ item.description
|
|
}}</span
|
|
>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
<p
|
|
id="upcoming-maintenance-empty"
|
|
class="empty-state"
|
|
{%
|
|
if
|
|
upcoming_maintenance|length
|
|
>
|
|
0 %} hidden{% endif %}> Schedule maintenance activities to track upcoming
|
|
events.
|
|
</p>
|
|
</section>
|
|
{% endblock %} {% block scripts %} {{ super() }}
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<script id="dashboard-data" type="application/json">
|
|
{{ {
|
|
"summary_metrics": summary_metrics,
|
|
"scenario_rows": scenario_rows,
|
|
"overall_report_metrics": overall_report_metrics,
|
|
"recent_simulations": recent_simulations,
|
|
"upcoming_maintenance": upcoming_maintenance,
|
|
"scenario_cost_chart": scenario_cost_chart,
|
|
"scenario_activity_chart": scenario_activity_chart,
|
|
"cost_chart_has_data": cost_chart_has_data,
|
|
"activity_chart_has_data": activity_chart_has_data,
|
|
"report_available": report_available
|
|
} | tojson }}
|
|
</script>
|
|
<script>
|
|
(() => {
|
|
const dataElement = document.getElementById("dashboard-data");
|
|
if (!dataElement) {
|
|
return;
|
|
}
|
|
|
|
let state = {};
|
|
try {
|
|
state = JSON.parse(dataElement.textContent || "{}");
|
|
} catch (error) {
|
|
console.error("Failed to parse dashboard data", error);
|
|
return;
|
|
}
|
|
|
|
const statusElement = document.getElementById("dashboard-status");
|
|
const summaryContainer = document.getElementById("summary-metrics");
|
|
const summaryEmpty = document.getElementById("summary-empty");
|
|
const scenarioTableBody = document.querySelector("#scenario-table tbody");
|
|
const scenarioEmpty = document.getElementById("scenario-table-empty");
|
|
const overallMetricsList = document.getElementById("overall-metrics");
|
|
const overallMetricsEmpty = document.getElementById(
|
|
"overall-metrics-empty"
|
|
);
|
|
const recentList = document.getElementById("recent-simulations");
|
|
const recentEmpty = document.getElementById("recent-simulations-empty");
|
|
const maintenanceList = document.getElementById("upcoming-maintenance");
|
|
const maintenanceEmpty = document.getElementById(
|
|
"upcoming-maintenance-empty"
|
|
);
|
|
const refreshButton = document.getElementById("refresh-dashboard");
|
|
const costChartCanvas = document.getElementById("cost-chart");
|
|
const costChartEmpty = document.getElementById("cost-chart-empty");
|
|
const activityChartCanvas = document.getElementById("activity-chart");
|
|
const activityChartEmpty = document.getElementById("activity-chart-empty");
|
|
|
|
let costChartInstance = null;
|
|
let activityChartInstance = null;
|
|
|
|
function setStatus(message, variant = "success") {
|
|
if (!statusElement) {
|
|
return;
|
|
}
|
|
if (!message) {
|
|
statusElement.hidden = true;
|
|
statusElement.textContent = "";
|
|
statusElement.classList.remove("success", "error");
|
|
return;
|
|
}
|
|
statusElement.textContent = message;
|
|
statusElement.hidden = false;
|
|
statusElement.classList.toggle("success", variant === "success");
|
|
statusElement.classList.toggle("error", variant !== "success");
|
|
}
|
|
|
|
function renderSummaryMetrics() {
|
|
if (!summaryContainer || !summaryEmpty) {
|
|
return;
|
|
}
|
|
summaryContainer.innerHTML = "";
|
|
const metrics = Array.isArray(state.summary_metrics)
|
|
? state.summary_metrics
|
|
: [];
|
|
metrics.forEach((metric) => {
|
|
const card = document.createElement("article");
|
|
card.className = "metric-card";
|
|
card.innerHTML = `
|
|
<span class="metric-label">${metric.label}</span>
|
|
<span class="metric-value">${metric.value}</span>
|
|
`;
|
|
summaryContainer.appendChild(card);
|
|
});
|
|
summaryEmpty.hidden = metrics.length > 0;
|
|
}
|
|
|
|
function renderScenarioTable() {
|
|
if (!scenarioTableBody || !scenarioEmpty) {
|
|
return;
|
|
}
|
|
scenarioTableBody.innerHTML = "";
|
|
const rows = Array.isArray(state.scenario_rows)
|
|
? state.scenario_rows
|
|
: [];
|
|
rows.forEach((row) => {
|
|
const tr = document.createElement("tr");
|
|
tr.innerHTML = `
|
|
<td>${row.scenario_name}</td>
|
|
<td>${row.parameter_display}</td>
|
|
<td>${row.equipment_display}</td>
|
|
<td>${row.capex_display}</td>
|
|
<td>${row.opex_display}</td>
|
|
<td>${row.production_display}</td>
|
|
<td>${row.consumption_display}</td>
|
|
<td>${row.maintenance_display}</td>
|
|
<td>${row.iterations_display}</td>
|
|
<td>${row.simulation_mean_display}</td>
|
|
`;
|
|
scenarioTableBody.appendChild(tr);
|
|
});
|
|
scenarioEmpty.hidden = rows.length > 0;
|
|
}
|
|
|
|
function renderOverallMetrics() {
|
|
if (!overallMetricsList || !overallMetricsEmpty) {
|
|
return;
|
|
}
|
|
overallMetricsList.innerHTML = "";
|
|
const items = Array.isArray(state.overall_report_metrics)
|
|
? state.overall_report_metrics
|
|
: [];
|
|
items.forEach((metric) => {
|
|
const li = document.createElement("li");
|
|
li.innerHTML = `
|
|
<span class="metric-label">${metric.label}</span>
|
|
<span class="metric-value">${metric.value}</span>
|
|
`;
|
|
overallMetricsList.appendChild(li);
|
|
});
|
|
overallMetricsEmpty.hidden =
|
|
Boolean(state.report_available) && items.length > 0;
|
|
if (!Boolean(state.report_available)) {
|
|
overallMetricsEmpty.hidden = false;
|
|
}
|
|
}
|
|
|
|
function renderRecentSimulations() {
|
|
if (!recentList || !recentEmpty) {
|
|
return;
|
|
}
|
|
recentList.innerHTML = "";
|
|
const runs = Array.isArray(state.recent_simulations)
|
|
? state.recent_simulations
|
|
: [];
|
|
runs.forEach((run) => {
|
|
const li = document.createElement("li");
|
|
li.innerHTML = `
|
|
<span class="list-title">${run.scenario_name}</span>
|
|
<span class="list-detail">Iterations: ${run.iterations_display} · Mean: ${run.mean_display} · P95: ${run.p95_display}</span>
|
|
`;
|
|
recentList.appendChild(li);
|
|
});
|
|
recentEmpty.hidden = runs.length > 0;
|
|
}
|
|
|
|
function renderMaintenance() {
|
|
if (!maintenanceList || !maintenanceEmpty) {
|
|
return;
|
|
}
|
|
maintenanceList.innerHTML = "";
|
|
const items = Array.isArray(state.upcoming_maintenance)
|
|
? state.upcoming_maintenance
|
|
: [];
|
|
items.forEach((item) => {
|
|
const li = document.createElement("li");
|
|
li.innerHTML = `
|
|
<span class="list-title">${item.equipment_name} · ${item.scenario_name}</span>
|
|
<span class="list-detail">${item.date_display} · ${item.cost_display} · ${item.description}</span>
|
|
`;
|
|
maintenanceList.appendChild(li);
|
|
});
|
|
maintenanceEmpty.hidden = items.length > 0;
|
|
}
|
|
|
|
function renderCostChart() {
|
|
if (!costChartCanvas || !costChartEmpty) {
|
|
return;
|
|
}
|
|
if (costChartInstance) {
|
|
costChartInstance.destroy();
|
|
costChartInstance = null;
|
|
}
|
|
const hasData =
|
|
Boolean(state.cost_chart_has_data) &&
|
|
Array.isArray(state.scenario_cost_chart?.labels) &&
|
|
state.scenario_cost_chart.labels.length > 0;
|
|
costChartCanvas.hidden = !hasData;
|
|
costChartEmpty.hidden = hasData;
|
|
if (!hasData) {
|
|
return;
|
|
}
|
|
costChartInstance = new Chart(costChartCanvas.getContext("2d"), {
|
|
type: "bar",
|
|
data: {
|
|
labels: state.scenario_cost_chart.labels,
|
|
datasets: [
|
|
{
|
|
label: "CAPEX",
|
|
data: state.scenario_cost_chart.capex,
|
|
backgroundColor: "#1d4ed8",
|
|
},
|
|
{
|
|
label: "OPEX",
|
|
data: state.scenario_cost_chart.opex,
|
|
backgroundColor: "#38bdf8",
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: {
|
|
stacked: false,
|
|
},
|
|
y: {
|
|
beginAtZero: true,
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
position: "bottom",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderActivityChart() {
|
|
if (!activityChartCanvas || !activityChartEmpty) {
|
|
return;
|
|
}
|
|
if (activityChartInstance) {
|
|
activityChartInstance.destroy();
|
|
activityChartInstance = null;
|
|
}
|
|
const hasData =
|
|
Boolean(state.activity_chart_has_data) &&
|
|
Array.isArray(state.scenario_activity_chart?.labels) &&
|
|
state.scenario_activity_chart.labels.length > 0;
|
|
activityChartCanvas.hidden = !hasData;
|
|
activityChartEmpty.hidden = hasData;
|
|
if (!hasData) {
|
|
return;
|
|
}
|
|
activityChartInstance = new Chart(activityChartCanvas.getContext("2d"), {
|
|
type: "line",
|
|
data: {
|
|
labels: state.scenario_activity_chart.labels,
|
|
datasets: [
|
|
{
|
|
label: "Production",
|
|
data: state.scenario_activity_chart.production,
|
|
borderColor: "#22c55e",
|
|
backgroundColor: "rgba(34, 197, 94, 0.18)",
|
|
tension: 0.35,
|
|
fill: true,
|
|
},
|
|
{
|
|
label: "Consumption",
|
|
data: state.scenario_activity_chart.consumption,
|
|
borderColor: "#ef4444",
|
|
backgroundColor: "rgba(239, 68, 68, 0.18)",
|
|
tension: 0.35,
|
|
fill: true,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: "bottom",
|
|
},
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderDashboard() {
|
|
renderSummaryMetrics();
|
|
renderScenarioTable();
|
|
renderOverallMetrics();
|
|
renderRecentSimulations();
|
|
renderMaintenance();
|
|
renderCostChart();
|
|
renderActivityChart();
|
|
}
|
|
|
|
function setLoading(isLoading) {
|
|
if (!refreshButton) {
|
|
return;
|
|
}
|
|
refreshButton.disabled = isLoading;
|
|
refreshButton.classList.toggle("is-loading", isLoading);
|
|
refreshButton.textContent = isLoading
|
|
? "Refreshing…"
|
|
: "Refresh Dashboard";
|
|
}
|
|
|
|
async function refreshDashboard() {
|
|
try {
|
|
setLoading(true);
|
|
setStatus("");
|
|
const response = await fetch("/ui/dashboard/data", {
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error("Failed to refresh dashboard data.");
|
|
}
|
|
const payload = await response.json();
|
|
state = payload;
|
|
renderDashboard();
|
|
setStatus("Dashboard updated.", "success");
|
|
} catch (error) {
|
|
console.error(error);
|
|
setStatus(error.message || "Unable to refresh dashboard.", "error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
if (refreshButton) {
|
|
refreshButton.addEventListener("click", refreshDashboard);
|
|
}
|
|
|
|
renderDashboard();
|
|
})();
|
|
</script>
|
|
{% endblock %}
|