399 lines
13 KiB
JavaScript
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 => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
"\"": """,
|
|
"'": "'"
|
|
}[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();
|
|
})();
|