402 lines
18 KiB
Plaintext
402 lines
18 KiB
Plaintext
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title><%= title %></title>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; margin: 0; background: #f5f7fb; color: #1f2937; }
|
|
.wrap { max-width: 1300px; margin: 20px auto; padding: 0 16px; }
|
|
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 16px; margin-bottom: 14px; }
|
|
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
|
input, button { padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 6px; }
|
|
button { background: #1d4ed8; border-color: #1d4ed8; color: #fff; cursor: pointer; }
|
|
button:hover { background: #1e40af; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
th, td { border: 1px solid #e5e7eb; padding: 8px; text-align: left; }
|
|
th { background: #f9fafb; }
|
|
.muted { color: #6b7280; font-size: 13px; }
|
|
.grid4 { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 10px; }
|
|
.kpi { background: #eef2ff; border: 1px solid #c7d2fe; border-radius: 8px; padding: 10px; }
|
|
.kpi b { display: block; font-size: 18px; margin-top: 2px; }
|
|
.err { color: #b91c1c; }
|
|
.ok { color: #065f46; }
|
|
.tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
.tabs button { background: #fff; color: #111827; border: 1px solid #d1d5db; }
|
|
.tabs button.active { background: #1d4ed8; color: #fff; border-color: #1d4ed8; }
|
|
.panel { display: none; }
|
|
.panel.active { display: block; }
|
|
.pager { display: flex; gap: 8px; align-items: center; margin: 10px 0; flex-wrap: wrap; }
|
|
.pager input { width: 220px; }
|
|
.run-monitor { margin-top: 12px; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; background: #f8fafc; }
|
|
.progress { width: 100%; height: 10px; background: #e5e7eb; border-radius: 999px; overflow: hidden; margin: 10px 0; }
|
|
.progress > div { height: 100%; background: #2563eb; width: 0%; transition: width 0.3s ease; }
|
|
.step-grid { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 8px; }
|
|
.step-card { border: 1px solid #d1d5db; border-radius: 6px; padding: 8px; background: #fff; }
|
|
.badge { display: inline-block; font-size: 11px; padding: 2px 6px; border-radius: 999px; }
|
|
.badge-pending { background: #f3f4f6; color: #374151; }
|
|
.badge-started { background: #dbeafe; color: #1d4ed8; }
|
|
.badge-completed { background: #dcfce7; color: #166534; }
|
|
.badge-failed { background: #fee2e2; color: #991b1b; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="card">
|
|
<h2 style="margin:0 0 8px 0;">IPI Calculation Demo Dashboard</h2>
|
|
<div class="muted">Use Swagger to trigger calculation first, then view data by year + month (month is mapped to quarter).</div>
|
|
<div class="row" style="margin-top:12px;">
|
|
<label>Year <input id="year" type="number" value="<%= defaultYear %>" min="2000" max="2100" /></label>
|
|
<label>Month <input id="month" type="number" value="<%= defaultMonth %>" min="1" max="12" /></label>
|
|
<button id="loadBtn">Load Data</button>
|
|
<button id="runBtn">Run Month Calculation</button>
|
|
<span id="status" class="muted"></span>
|
|
</div>
|
|
|
|
<div class="run-monitor">
|
|
<div class="row" style="justify-content: space-between;">
|
|
<b>Live Run Monitor</b>
|
|
<span id="runState" class="muted">Idle</span>
|
|
</div>
|
|
<div class="progress"><div id="runProgress"></div></div>
|
|
<div id="stepGrid" class="step-grid"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="grid4">
|
|
<div class="kpi">Item-level rows<b id="kpiItem">-</b></div>
|
|
<div class="kpi">ISIC 4-digit rows<b id="kpi4">-</b></div>
|
|
<div class="kpi">ISIC 3-digit rows<b id="kpi3">-</b></div>
|
|
<div class="kpi">ISIC 2-digit rows<b id="kpi2">-</b></div>
|
|
</div>
|
|
<div style="margin-top:12px;">
|
|
<table>
|
|
<thead><tr><th>Manufacturing Index</th><th>MoM %</th><th>YoY %</th><th>Total Weight</th><th>Weighted Index Sum</th></tr></thead>
|
|
<tbody id="manufacturingBody"><tr><td colspan="5" class="muted">No data loaded.</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="tabs">
|
|
<button class="tab active" data-tab="isic4">ISIC 4-digit</button>
|
|
<button class="tab" data-tab="isic3">ISIC 3-digit</button>
|
|
<button class="tab" data-tab="isic2">ISIC 2-digit</button>
|
|
<button class="tab" data-tab="monthly">Monthly Production</button>
|
|
<button class="tab" data-tab="baseyear">Base Year Production</button>
|
|
<button class="tab" data-tab="items">Item Level Indices</button>
|
|
<button class="tab" data-tab="logs">Calculation Logs</button>
|
|
</div>
|
|
|
|
<div id="tableControls" class="pager" style="display:none;">
|
|
<input id="tableSearch" type="text" placeholder="Search product_id / hs_code..." />
|
|
<button id="searchBtn">Search</button>
|
|
<button id="prevBtn">Prev</button>
|
|
<button id="nextBtn">Next</button>
|
|
<span id="pageInfo" class="muted"></span>
|
|
</div>
|
|
|
|
<div id="panel-isic4" class="panel active"></div>
|
|
<div id="panel-isic3" class="panel"></div>
|
|
<div id="panel-isic2" class="panel"></div>
|
|
<div id="panel-monthly" class="panel"></div>
|
|
<div id="panel-baseyear" class="panel"></div>
|
|
<div id="panel-items" class="panel"></div>
|
|
<div id="panel-logs" class="panel"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const statusEl = document.getElementById("status");
|
|
const pagedTabs = new Set(["monthly", "baseyear", "items"]);
|
|
const tableState = {
|
|
monthly: { page: 1, pageSize: 50, total: 0, search: "" },
|
|
baseyear: { page: 1, pageSize: 50, total: 0, search: "" },
|
|
items: { page: 1, pageSize: 50, total: 0, search: "" },
|
|
};
|
|
let activeTab = "isic4";
|
|
let pollTimer = null;
|
|
|
|
function tableHtml(columns, rows) {
|
|
if (!rows || rows.length === 0) return '<div class="muted">No rows found.</div>';
|
|
const head = `<thead><tr>${columns.map(c => `<th>${c.label}</th>`).join("")}</tr></thead>`;
|
|
const bodyRows = rows.map(r => `<tr>${columns.map(c => `<td>${r[c.key] ?? ""}</td>`).join("")}</tr>`).join("");
|
|
return `<table>${head}<tbody>${bodyRows}</tbody></table>`;
|
|
}
|
|
|
|
function render(data) {
|
|
document.getElementById("kpiItem").textContent = data.summary.item_count;
|
|
document.getElementById("kpi4").textContent = data.summary.isic4_count;
|
|
document.getElementById("kpi3").textContent = data.summary.isic3_count;
|
|
document.getElementById("kpi2").textContent = data.summary.isic2_count;
|
|
|
|
const m = data.manufacturing;
|
|
const mBody = document.getElementById("manufacturingBody");
|
|
if (!m) {
|
|
mBody.innerHTML = `<tr><td colspan="5" class="muted">No manufacturing index for selected period.</td></tr>`;
|
|
} else {
|
|
mBody.innerHTML = `<tr>
|
|
<td>${m.manufacturing_index ?? ""}</td>
|
|
<td>${m.mom_change ?? ""}</td>
|
|
<td>${m.yoy_change ?? ""}</td>
|
|
<td>${m.total_weight ?? ""}</td>
|
|
<td>${m.weighted_index_sum ?? ""}</td>
|
|
</tr>`;
|
|
}
|
|
|
|
document.getElementById("panel-isic4").innerHTML = tableHtml(
|
|
[
|
|
{ key: "isic_4digit_code", label: "ISIC 4 Code" },
|
|
{ key: "isic_description", label: "Description" },
|
|
{ key: "total_weight", label: "Total Weight" },
|
|
{ key: "weighted_index_sum", label: "Weighted Index Sum" },
|
|
{ key: "isic_4digit_index", label: "ISIC 4 Index" },
|
|
],
|
|
data.isic4
|
|
);
|
|
|
|
document.getElementById("panel-isic3").innerHTML = tableHtml(
|
|
[
|
|
{ key: "isic_3digit_code", label: "ISIC 3 Code" },
|
|
{ key: "isic_description", label: "Description" },
|
|
{ key: "total_weight", label: "Total Weight" },
|
|
{ key: "weighted_index_sum", label: "Weighted Index Sum" },
|
|
{ key: "isic_3digit_index", label: "ISIC 3 Index" },
|
|
],
|
|
data.isic3
|
|
);
|
|
|
|
document.getElementById("panel-isic2").innerHTML = tableHtml(
|
|
[
|
|
{ key: "isic_2digit_code", label: "ISIC 2 Code" },
|
|
{ key: "isic_description", label: "Description" },
|
|
{ key: "total_weight", label: "Total Weight" },
|
|
{ key: "weighted_index_sum", label: "Weighted Index Sum" },
|
|
{ key: "isic_2digit_index", label: "ISIC 2 Index" },
|
|
],
|
|
data.isic2
|
|
);
|
|
|
|
document.getElementById("panel-monthly").innerHTML = '<div class="muted">Loading monthly production...</div>';
|
|
document.getElementById("panel-baseyear").innerHTML = '<div class="muted">Loading base year production...</div>';
|
|
document.getElementById("panel-items").innerHTML = '<div class="muted">Loading item level indices...</div>';
|
|
|
|
document.getElementById("panel-logs").innerHTML = tableHtml(
|
|
[
|
|
{ key: "calculation_type", label: "Type" },
|
|
{ key: "status", label: "Status" },
|
|
{ key: "records_processed", label: "Records" },
|
|
{ key: "started_at", label: "Started At" },
|
|
{ key: "completed_at", label: "Completed At" },
|
|
{ key: "error_message", label: "Error" },
|
|
],
|
|
data.logs
|
|
);
|
|
|
|
loadPagedTable("monthly");
|
|
loadPagedTable("baseyear");
|
|
loadPagedTable("items");
|
|
refreshTableControls();
|
|
}
|
|
|
|
function statusBadge(status) {
|
|
const s = String(status || "Pending");
|
|
const lower = s.toLowerCase();
|
|
if (lower === "completed") return `<span class="badge badge-completed">Completed</span>`;
|
|
if (lower === "started") return `<span class="badge badge-started">Started</span>`;
|
|
if (lower === "failed") return `<span class="badge badge-failed">Failed</span>`;
|
|
return `<span class="badge badge-pending">Pending</span>`;
|
|
}
|
|
|
|
function renderRunStatus(data) {
|
|
document.getElementById("runProgress").style.width = `${data.progressPercent || 0}%`;
|
|
const state = data.isFinished
|
|
? (data.failedStep ? `Failed at ${data.failedStep}` : "Completed")
|
|
: (data.started ? "Running..." : "Idle");
|
|
document.getElementById("runState").textContent = `${state} (${data.completedCount || 0}/${data.totalSteps || 6})`;
|
|
|
|
const html = (data.steps || []).map((s) => `
|
|
<div class="step-card">
|
|
<div><b>${s.step}</b> ${statusBadge(s.status)}</div>
|
|
<div class="muted">Records: ${s.records_processed ?? 0}</div>
|
|
<div class="muted">Started: ${s.started_at || "-"}</div>
|
|
<div class="muted">Completed: ${s.completed_at || "-"}</div>
|
|
${s.error_message ? `<div class="err">Error: ${s.error_message}</div>` : ""}
|
|
</div>
|
|
`).join("");
|
|
document.getElementById("stepGrid").innerHTML = html || '<div class="muted">No run status yet.</div>';
|
|
}
|
|
|
|
async function fetchRunStatus() {
|
|
const year = document.getElementById("year").value;
|
|
const month = document.getElementById("month").value;
|
|
try {
|
|
const resp = await fetch(`/ipi-demo/run-status?year=${encodeURIComponent(year)}&month=${encodeURIComponent(month)}`);
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.message || "Status fetch failed");
|
|
renderRunStatus(data);
|
|
if (data.isFinished && pollTimer) {
|
|
clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
loadData();
|
|
}
|
|
} catch (err) {
|
|
document.getElementById("runState").textContent = err.message;
|
|
}
|
|
}
|
|
|
|
function getColumnsForTable(tab) {
|
|
if (tab === "monthly") {
|
|
return [
|
|
{ key: "product_id", label: "Product ID" },
|
|
{ key: "product_hs_code", label: "HS Code" },
|
|
{ key: "year", label: "Year" },
|
|
{ key: "quarter", label: "Quarter" },
|
|
{ key: "quarter_name", label: "Quarter Name" },
|
|
{ key: "production_quantity", label: "Production Qty" },
|
|
{ key: "unit", label: "Unit" },
|
|
];
|
|
}
|
|
if (tab === "baseyear") {
|
|
return [
|
|
{ key: "product_id", label: "Product ID" },
|
|
{ key: "product_hs_code", label: "HS Code" },
|
|
{ key: "base_year", label: "Base Year" },
|
|
{ key: "avg_by_production", label: "Avg Base Year Prod." },
|
|
{ key: "q1", label: "Q1" },
|
|
{ key: "q2", label: "Q2" },
|
|
{ key: "q3", label: "Q3" },
|
|
{ key: "q4", label: "Q4" },
|
|
];
|
|
}
|
|
return [
|
|
{ key: "product_id", label: "Product ID" },
|
|
{ key: "product_hs_code", label: "HS Code" },
|
|
{ key: "current_production", label: "Current Production" },
|
|
{ key: "base_year_avg_production", label: "Base Avg" },
|
|
{ key: "production_relative", label: "Relative" },
|
|
{ key: "item_index", label: "Item Index" },
|
|
];
|
|
}
|
|
|
|
async function loadPagedTable(tab) {
|
|
const year = document.getElementById("year").value;
|
|
const month = document.getElementById("month").value;
|
|
const panel = document.getElementById(`panel-${tab}`);
|
|
const state = tableState[tab];
|
|
panel.innerHTML = '<div class="muted">Loading...</div>';
|
|
try {
|
|
const url = `/ipi-demo/table-data?table=${tab}&year=${encodeURIComponent(year)}&month=${encodeURIComponent(month)}&page=${state.page}&pageSize=${state.pageSize}&search=${encodeURIComponent(state.search)}`;
|
|
const resp = await fetch(url);
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.message || "Load failed");
|
|
state.total = data.total;
|
|
panel.innerHTML = tableHtml(getColumnsForTable(tab), data.rows);
|
|
if (activeTab === tab) refreshTableControls();
|
|
} catch (err) {
|
|
panel.innerHTML = `<div class="err">${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function refreshTableControls() {
|
|
const controls = document.getElementById("tableControls");
|
|
if (!pagedTabs.has(activeTab)) {
|
|
controls.style.display = "none";
|
|
return;
|
|
}
|
|
controls.style.display = "flex";
|
|
const st = tableState[activeTab];
|
|
document.getElementById("tableSearch").value = st.search;
|
|
const totalPages = Math.max(1, Math.ceil(st.total / st.pageSize));
|
|
document.getElementById("pageInfo").textContent = `Page ${st.page} / ${totalPages} (Total: ${st.total}, Page size: ${st.pageSize})`;
|
|
}
|
|
|
|
async function loadData() {
|
|
const year = document.getElementById("year").value;
|
|
const month = document.getElementById("month").value;
|
|
statusEl.textContent = "Loading...";
|
|
statusEl.className = "muted";
|
|
try {
|
|
const resp = await fetch(`/ipi-demo/data?year=${encodeURIComponent(year)}&month=${encodeURIComponent(month)}`);
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.message || "Failed to load");
|
|
render(data);
|
|
statusEl.textContent = "Loaded successfully";
|
|
statusEl.className = "ok";
|
|
} catch (err) {
|
|
statusEl.textContent = err.message;
|
|
statusEl.className = "err";
|
|
}
|
|
}
|
|
|
|
document.getElementById("loadBtn").addEventListener("click", loadData);
|
|
document.getElementById("runBtn").addEventListener("click", async () => {
|
|
const year = Number(document.getElementById("year").value);
|
|
const month = Number(document.getElementById("month").value);
|
|
statusEl.textContent = "Starting run...";
|
|
statusEl.className = "muted";
|
|
if (pollTimer) clearInterval(pollTimer);
|
|
pollTimer = setInterval(fetchRunStatus, 2500);
|
|
try {
|
|
const resp = await fetch("/ipi-demo/run-month", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ year, month }),
|
|
});
|
|
const data = await resp.json();
|
|
if (!resp.ok || !data.success) throw new Error(data.message || "Run failed");
|
|
statusEl.textContent = "Run completed";
|
|
statusEl.className = "ok";
|
|
await fetchRunStatus();
|
|
await loadData();
|
|
} catch (err) {
|
|
statusEl.textContent = err.message;
|
|
statusEl.className = "err";
|
|
}
|
|
});
|
|
document.querySelectorAll(".tab").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
document.querySelectorAll(".tab").forEach((b) => b.classList.remove("active"));
|
|
document.querySelectorAll(".panel").forEach((p) => p.classList.remove("active"));
|
|
btn.classList.add("active");
|
|
document.getElementById(`panel-${btn.dataset.tab}`).classList.add("active");
|
|
activeTab = btn.dataset.tab;
|
|
refreshTableControls();
|
|
});
|
|
});
|
|
|
|
document.getElementById("searchBtn").addEventListener("click", () => {
|
|
if (!pagedTabs.has(activeTab)) return;
|
|
tableState[activeTab].search = document.getElementById("tableSearch").value.trim();
|
|
tableState[activeTab].page = 1;
|
|
loadPagedTable(activeTab);
|
|
});
|
|
|
|
document.getElementById("prevBtn").addEventListener("click", () => {
|
|
if (!pagedTabs.has(activeTab)) return;
|
|
const st = tableState[activeTab];
|
|
if (st.page > 1) {
|
|
st.page -= 1;
|
|
loadPagedTable(activeTab);
|
|
}
|
|
});
|
|
|
|
document.getElementById("nextBtn").addEventListener("click", () => {
|
|
if (!pagedTabs.has(activeTab)) return;
|
|
const st = tableState[activeTab];
|
|
const totalPages = Math.max(1, Math.ceil(st.total / st.pageSize));
|
|
if (st.page < totalPages) {
|
|
st.page += 1;
|
|
loadPagedTable(activeTab);
|
|
}
|
|
});
|
|
|
|
loadData();
|
|
fetchRunStatus();
|
|
pollTimer = setInterval(fetchRunStatus, 2500);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|