AVS-Carton-Shipment-Verifier/wwwroot/js/movement-analytics.js

399 lines
13 KiB
JavaScript

(() => {
const pollIntervalMs = 5000;
let barChart = null;
let donutChart = null;
let pollTimer = null;
let lastAnalytics = null;
const els = {
dialog: () => document.getElementById("scan-history-dialog"),
recordsView: () => document.getElementById("scan-history-records-view"),
analyticsView: () => document.getElementById("scan-history-analytics-view"),
filterRecords: () => document.getElementById("scan-history-filter"),
btnAnalytics: () => document.getElementById("btn-movement-analytics"),
dateFrom: () => document.getElementById("analytics-date-from"),
dateTo: () => document.getElementById("analytics-date-to"),
exportBtn: () => document.getElementById("btn-analytics-export"),
footerClose: () => document.getElementById("btn-scan-history-dialog-close"),
summaryToday: () => document.getElementById("summary-moved-today"),
summaryWeek: () => document.getElementById("summary-moved-week"),
summaryMonth: () => document.getElementById("summary-moved-month"),
summaryTotal: () => document.getElementById("summary-total-moved"),
dayTbody: () => document.getElementById("analytics-day-tbody"),
legend: () => document.getElementById("analytics-status-legend")
};
function formatCount(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return "0";
}
return Number(value).toLocaleString("en-US");
}
function toInputDate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function setDefaultDateRange() {
const dateFrom = els.dateFrom();
const dateTo = els.dateTo();
if (!dateFrom || !dateTo) {
return;
}
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 6);
dateTo.value = toInputDate(to);
dateFrom.value = toInputDate(from);
}
function setModeButtonActive(active) {
const btn = els.btnAnalytics();
if (!btn) {
return;
}
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-pressed", active ? "true" : "false");
}
function setAnalyticsVisible(visible) {
const recordsView = els.recordsView();
const analyticsView = els.analyticsView();
const dialog = els.dialog();
if (!recordsView || !analyticsView) {
return;
}
if (visible) {
recordsView.classList.add("is-hidden");
recordsView.hidden = true;
analyticsView.classList.remove("is-hidden");
analyticsView.hidden = false;
dialog?.classList.add("scan-history-dialog--wide");
setModeButtonActive(true);
setDefaultDateRange();
void refreshMovementAnalytics();
startPolling();
return;
}
analyticsView.classList.add("is-hidden");
analyticsView.hidden = true;
recordsView.classList.remove("is-hidden");
recordsView.hidden = false;
dialog?.classList.remove("scan-history-dialog--wide");
setModeButtonActive(false);
stopPolling();
}
function startPolling() {
stopPolling();
pollTimer = window.setInterval(() => {
if (els.analyticsView()?.hidden) {
return;
}
void refreshMovementAnalytics();
}, pollIntervalMs);
}
function stopPolling() {
if (pollTimer !== null) {
window.clearInterval(pollTimer);
pollTimer = null;
}
}
async function refreshMovementAnalytics() {
const dateFrom = els.dateFrom();
const dateTo = els.dateTo();
if (!dateFrom?.value || !dateTo?.value) {
return;
}
const url = `/api/verification/movement-analytics?from=${encodeURIComponent(dateFrom.value)}&to=${encodeURIComponent(dateTo.value)}`;
try {
const response = await fetch(url);
if (!response.ok) {
return;
}
const data = await response.json();
lastAnalytics = data;
renderMovementAnalytics(data);
} catch {
// Keep last rendered values on failure.
}
}
function renderMovementAnalytics(data) {
const summary = data.summary ?? {};
const statusShare = data.statusShare ?? {};
if (els.summaryToday()) {
els.summaryToday().textContent = formatCount(summary.movedToday);
}
if (els.summaryWeek()) {
els.summaryWeek().textContent = formatCount(summary.movedThisWeek);
}
if (els.summaryMonth()) {
els.summaryMonth().textContent = formatCount(summary.movedThisMonth);
}
if (els.summaryTotal()) {
els.summaryTotal().textContent = formatCount(summary.totalMoved);
}
renderBarChart(data.chartByDay ?? []);
renderDonutChart(statusShare);
renderDayTable(data.dayDetails ?? []);
}
function renderBarChart(chartByDay) {
const canvas = document.getElementById("analytics-bar-chart");
if (!canvas || typeof Chart === "undefined") {
return;
}
const labels = chartByDay.map(x => x.dayLabel);
const values = chartByDay.map(x => x.movedCartons);
if (barChart) {
barChart.destroy();
}
barChart = new Chart(canvas, {
type: "bar",
data: {
labels,
datasets: [{
label: "Moved cartons",
data: values,
backgroundColor: "#1a5fb4",
borderRadius: 4,
maxBarThickness: 48
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: "bottom",
labels: { boxWidth: 12, color: "#4f6687" }
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: "#4f6687" }
},
y: {
beginAtZero: true,
title: { display: true, text: "Cartons", color: "#4f6687" },
ticks: { color: "#4f6687" }
}
}
}
});
}
function renderDonutChart(statusShare) {
const canvas = document.getElementById("analytics-donut-chart");
const legend = els.legend();
if (!canvas || typeof Chart === "undefined") {
return;
}
const moved = statusShare.moved ?? 0;
const pending = statusShare.pending ?? 0;
const total = statusShare.total ?? moved + pending;
const movedPct = statusShare.movedPercent ?? (total > 0 ? Math.round(moved * 1000 / total) / 10 : 0);
const pendingPct = statusShare.pendingPercent ?? (total > 0 ? Math.round(pending * 1000 / total) / 10 : 0);
if (donutChart) {
donutChart.destroy();
}
donutChart = new Chart(canvas, {
type: "doughnut",
data: {
labels: ["Moved", "Pending"],
datasets: [{
data: [moved, pending],
backgroundColor: ["#3cae58", "#d5dde8"],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: "62%",
plugins: {
legend: { display: false },
tooltip: { enabled: true }
}
},
plugins: [{
id: "centerText",
beforeDraw(chart) {
const { ctx, chartArea } = chart;
if (!chartArea) {
return;
}
ctx.save();
ctx.font = "bold 14px Segoe UI, sans-serif";
ctx.fillStyle = "#0d3b7a";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`Total ${formatCount(total)}`, (chartArea.left + chartArea.right) / 2, (chartArea.top + chartArea.bottom) / 2);
ctx.restore();
}
}]
});
if (legend) {
legend.innerHTML = `
<li><span class="analytics-legend-swatch analytics-legend-moved"></span> Moved <strong>${formatCount(moved)}</strong> (${movedPct}%)</li>
<li><span class="analytics-legend-swatch analytics-legend-pending"></span> Pending <strong>${formatCount(pending)}</strong> (${pendingPct}%)</li>`;
}
}
function formatLastMovedTime(utcValue) {
if (!utcValue) {
return "—";
}
const date = new Date(utcValue);
if (Number.isNaN(date.getTime())) {
return "—";
}
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true
});
}
function renderDayTable(dayDetails) {
const tbody = els.dayTbody();
if (!tbody) {
return;
}
if (!dayDetails.length) {
tbody.innerHTML = `<tr><td colspan="6" class="scan-history-modal-empty">No movement data for this date range.</td></tr>`;
return;
}
tbody.innerHTML = "";
dayDetails.forEach(row => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${escapeHtml(row.dateDisplay)}</td>
<td>${formatCount(row.movedCartons)}</td>
<td>${formatCount(row.successfulScans)}</td>
<td>${formatCount(row.duplicateRejections)}</td>
<td>${formatCount(row.manualEntryRejections)}</td>
<td>${formatLastMovedTime(row.lastMovedAtUtc)}</td>`;
tbody.appendChild(tr);
});
}
function escapeHtml(value) {
return (value ?? "").replace(/[&<>\"']/g, char => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;"
}[char]));
}
function exportDayDetailsCsv() {
if (!lastAnalytics?.dayDetails?.length) {
return;
}
const header = ["Date", "Moved Cartons", "Successful Scans", "Duplicate Rejections", "Manual Entry Rejections", "Last Moved At"];
const rows = lastAnalytics.dayDetails.map(row => [
row.dateDisplay,
row.movedCartons,
row.successfulScans,
row.duplicateRejections,
row.manualEntryRejections,
row.lastMovedAtUtc ? new Date(row.lastMovedAtUtc).toLocaleString() : ""
]);
const csv = [header, ...rows]
.map(cols => cols.map(col => `"${String(col).replace(/"/g, '""')}"`).join(","))
.join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `movement-analytics-${els.dateFrom()?.value ?? "export"}.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
function wire() {
const btnAnalytics = els.btnAnalytics();
const dateFrom = els.dateFrom();
const dateTo = els.dateTo();
const exportBtn = els.exportBtn();
const footerClose = document.getElementById("scan-history-dialog-close");
const footerCloseAlt = els.footerClose();
const onDateChange = () => void refreshMovementAnalytics();
dateFrom?.addEventListener("change", onDateChange);
dateTo?.addEventListener("change", onDateChange);
exportBtn?.addEventListener("click", exportDayDetailsCsv);
const closeDialog = () => {
const dialog = els.dialog();
if (dialog && typeof dialog.close === "function") {
dialog.close();
}
};
footerClose?.addEventListener("click", closeDialog);
footerCloseAlt?.addEventListener("click", closeDialog);
btnAnalytics?.addEventListener("click", () => {
const analyticsView = els.analyticsView();
const isOpen = analyticsView && !analyticsView.hidden;
if (isOpen) {
setAnalyticsVisible(false);
window.dispatchEvent(new CustomEvent("avs-history-show-records"));
return;
}
setAnalyticsVisible(true);
});
}
window.avsMovementAnalytics = {
wire,
setAnalyticsVisible,
refreshMovementAnalytics,
stopPolling
};
window.avsMovementAnalytics.wire();
})();