Add movement analytics dashboard UI

Adds/updates the dashboard UI, styling, analytics chart/table behavior, and client-side scan/dashboard interactions.
main
SYED MUSTUFA AHMED NAQVI 2026-05-15 18:14:51 +05:00
parent 2430700dbf
commit 4c647569cd
4 changed files with 935 additions and 55 deletions

View File

@ -16,7 +16,6 @@
<main class="dashboard-main"> <main class="dashboard-main">
<section class="top-grid"> <section class="top-grid">
<section class="left-column">
<section class="panel panel-scan"> <section class="panel panel-scan">
<h2 class="section-title"><span class="section-icon">&#128438;</span> SCAN QR CODE</h2> <h2 class="section-title"><span class="section-icon">&#128438;</span> SCAN QR CODE</h2>
<form id="scan-form" autocomplete="off" novalidate onsubmit="return false;"> <form id="scan-form" autocomplete="off" novalidate onsubmit="return false;">
@ -35,6 +34,16 @@
</form> </form>
</section> </section>
<section class="panel panel-scanned-details">
<h2 class="section-title"><span class="section-icon">&#9432;</span> SCANNED DETAILS</h2>
<div class="info-grid">
<span>Model Number</span><strong id="detail-model">-</strong>
<span>Unique Number</span><strong id="detail-unique">-</strong>
</div>
</section>
</section>
<section class="kpi-grid">
<section id="status-card" class="panel panel-status status-neutral" aria-live="polite"> <section id="status-card" class="panel panel-status status-neutral" aria-live="polite">
<h2 class="section-title"><span class="section-icon">&#8767;</span> VERIFICATION STATUS</h2> <h2 class="section-title"><span class="section-icon">&#8767;</span> VERIFICATION STATUS</h2>
<div class="status-mini-icon" id="status-mini-icon">-</div> <div class="status-mini-icon" id="status-mini-icon">-</div>
@ -45,18 +54,14 @@
<strong id="last-updated-time">-</strong> <strong id="last-updated-time">-</strong>
</div> </div>
</section> </section>
<section class="panel panel-available-cartons" aria-live="polite">
<h2 class="section-title section-title-center"><span class="section-icon">&#128230;</span> AVAILABLE CARTONS</h2>
<div id="available-cartons-count" class="available-cartons-value">@(Model.AvailableCartonsCount.HasValue ? Model.AvailableCartonsCount.Value.ToString("N0") : "—")</div>
<div class="available-cartons-subtitle">Not moved to warehouse</div>
</section> </section>
<section class="right-column"> <section class="panel panel-verification-result">
<section class="panel">
<h2 class="section-title"><span class="section-icon">&#9432;</span> SCANNED DETAILS</h2>
<div class="info-grid">
<span>Model Number</span><strong id="detail-model">-</strong>
<span>Unique Number</span><strong id="detail-unique">-</strong>
</div>
</section>
<section class="panel">
<h2 class="section-title"><span class="section-icon">&#128737;</span> VERIFICATION RESULT</h2> <h2 class="section-title"><span class="section-icon">&#128737;</span> VERIFICATION RESULT</h2>
<div class="info-grid"> <div class="info-grid">
<span>Exists in Carton Logs</span><strong id="detail-exists">-</strong> <span>Exists in Carton Logs</span><strong id="detail-exists">-</strong>
@ -66,7 +71,6 @@
</div> </div>
</section> </section>
</section> </section>
</section>
<section class="panel panel-history"> <section class="panel panel-history">
<div class="panel-history-head"> <div class="panel-history-head">
@ -151,19 +155,23 @@
</div> </div>
</section> </section>
<dialog id="scan-history-dialog" class="scan-history-dialog" aria-labelledby="scan-history-dialog-title"> <dialog id="scan-history-dialog" class="scan-history-dialog scan-history-dialog--wide" aria-labelledby="scan-history-dialog-title">
<div class="scan-history-dialog-content"> <div class="scan-history-dialog-content">
<header class="scan-history-dialog-header"> <header class="scan-history-dialog-header">
<h2 id="scan-history-dialog-title" class="scan-history-dialog-title">Scan history</h2> <h2 id="scan-history-dialog-title" class="scan-history-dialog-title">Scan History &amp; Analytics</h2>
<button type="button" class="scan-history-dialog-close" id="scan-history-dialog-close" aria-label="Close">&times;</button> <button type="button" class="scan-history-dialog-close" id="scan-history-dialog-close" aria-label="Close">&times;</button>
</header> </header>
<div class="scan-history-dialog-toolbar">
<div class="scan-history-dialog-toolbar scan-history-mode-toolbar">
<label class="scan-history-filter-label" for="scan-history-filter">Show</label> <label class="scan-history-filter-label" for="scan-history-filter">Show</label>
<select id="scan-history-filter" class="scan-history-filter" aria-label="Record type"> <select id="scan-history-filter" class="scan-history-filter" aria-label="Record type">
<option value="successful">Successful Records</option> <option value="successful">Successful Records</option>
<option value="rejected">Rejected Records</option> <option value="rejected">Rejected Records</option>
</select> </select>
<button type="button" class="btn-history-mode" id="btn-movement-analytics">Movement Analytics</button>
</div> </div>
<div id="scan-history-records-view">
<div class="scan-history-dialog-body table-responsive"> <div class="scan-history-dialog-body table-responsive">
<table class="table table-sm align-middle" id="scan-history-modal-table"> <table class="table table-sm align-middle" id="scan-history-modal-table">
<thead id="scan-history-modal-thead"></thead> <thead id="scan-history-modal-thead"></thead>
@ -171,6 +179,93 @@
</table> </table>
</div> </div>
</div> </div>
<div id="scan-history-analytics-view" class="scan-history-analytics-view is-hidden" hidden>
<div class="scan-history-dialog-toolbar analytics-toolbar">
<div class="analytics-toolbar-filters">
<label class="scan-history-filter-label" for="analytics-date-from">Date Range</label>
<input type="date" id="analytics-date-from" class="analytics-date-input" aria-label="From date" />
<span class="analytics-date-sep"></span>
<input type="date" id="analytics-date-to" class="analytics-date-input" aria-label="To date" />
<label class="scan-history-filter-label" for="analytics-group-by">Show</label>
<select id="analytics-group-by" class="scan-history-filter" aria-label="Grouping">
<option value="day" selected>Day-wise</option>
</select>
</div>
<button type="button" class="btn-analytics-export" id="btn-analytics-export">Export</button>
</div>
<div class="analytics-summary-grid">
<article class="analytics-summary-card">
<span class="analytics-summary-icon analytics-icon-box" aria-hidden="true">&#128230;</span>
<div>
<div class="analytics-summary-value" id="summary-moved-today">0</div>
<div class="analytics-summary-label">Moved Today</div>
</div>
</article>
<article class="analytics-summary-card">
<span class="analytics-summary-icon analytics-icon-week" aria-hidden="true">&#128197;</span>
<div>
<div class="analytics-summary-value" id="summary-moved-week">0</div>
<div class="analytics-summary-label">Moved This Week</div>
</div>
</article>
<article class="analytics-summary-card">
<span class="analytics-summary-icon analytics-icon-month" aria-hidden="true">&#128197;</span>
<div>
<div class="analytics-summary-value" id="summary-moved-month">0</div>
<div class="analytics-summary-label">Moved This Month</div>
</div>
</article>
<article class="analytics-summary-card">
<span class="analytics-summary-icon analytics-icon-total" aria-hidden="true">&#128230;</span>
<div>
<div class="analytics-summary-value" id="summary-total-moved">0</div>
<div class="analytics-summary-label">Total Moved</div>
</div>
</article>
</div>
<div class="analytics-charts-row">
<section class="analytics-chart-panel">
<h3 class="analytics-panel-title">Cartons Moved to Warehouse by Day</h3>
<div class="analytics-bar-chart-wrap">
<canvas id="analytics-bar-chart" aria-label="Cartons moved by day"></canvas>
</div>
</section>
<section class="analytics-chart-panel analytics-donut-panel">
<h3 class="analytics-panel-title">Status Share</h3>
<div class="analytics-donut-wrap">
<canvas id="analytics-donut-chart" aria-label="Moved versus pending share"></canvas>
</div>
<ul class="analytics-legend" id="analytics-status-legend"></ul>
</section>
</div>
<section class="analytics-table-section">
<h3 class="analytics-panel-title">Day-wise Movement Details</h3>
<div class="table-responsive analytics-table-wrap">
<table class="table table-sm align-middle" id="analytics-day-table">
<thead>
<tr>
<th>DATE</th>
<th>MOVED CARTONS</th>
<th>SUCCESSFUL SCANS</th>
<th>DUPLICATE REJECTIONS</th>
<th>MANUAL ENTRY REJECTIONS</th>
<th>LAST MOVED AT</th>
</tr>
</thead>
<tbody id="analytics-day-tbody"></tbody>
</table>
</div>
</section>
<div class="scan-history-dialog-footer">
<button type="button" class="btn-scan-history-dialog-close" id="btn-scan-history-dialog-close">Close</button>
</div>
</div>
</div>
</dialog> </dialog>
</main> </main>
@ -181,5 +276,7 @@
</footer> </footer>
@section Scripts { @section Scripts {
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
<script src="~/js/movement-analytics.js" asp-append-version="true"></script>
<script src="~/js/verification-dashboard.js" asp-append-version="true"></script> <script src="~/js/verification-dashboard.js" asp-append-version="true"></script>
} }

View File

@ -62,10 +62,45 @@ body {
gap: 10px; gap: 10px;
} }
.left-column, .kpi-grid {
.right-column {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px; gap: 10px;
align-items: stretch;
}
.section-title-center {
justify-content: center;
}
.panel-available-cartons {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
min-height: 100%;
}
.available-cartons-value {
font-size: 3.25rem;
font-weight: 800;
color: #0d3b7a;
line-height: 1.05;
letter-spacing: -0.5px;
margin: 4px 0 2px;
}
.available-cartons-subtitle {
color: #637898;
font-size: 1.05rem;
font-weight: 600;
}
.panel-status,
.panel-verification-result,
.panel-scanned-details {
min-height: 100%;
} }
.panel { .panel {
@ -259,6 +294,10 @@ body {
color: #223a5f; color: #223a5f;
} }
.scan-history-dialog--wide {
max-width: min(1180px, 98vw);
}
.scan-history-dialog::backdrop { .scan-history-dialog::backdrop {
background: rgba(11, 47, 107, 0.5); background: rgba(11, 47, 107, 0.5);
} }
@ -328,6 +367,40 @@ body {
font-size: 0.88rem; font-size: 0.88rem;
} }
.scan-history-mode-toolbar {
align-items: center;
gap: 10px;
}
.btn-history-mode {
appearance: none;
border: 1px solid #1b5caa;
background: #fff;
color: #0d4a9c;
font-weight: 700;
font-size: 0.85rem;
padding: 7px 14px;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
margin-left: 4px;
}
.btn-history-mode:hover {
background: #ebf4ff;
}
.btn-history-mode.is-active {
background: #0d4a9c;
color: #fff;
border-color: #0d4a9c;
}
.btn-history-mode:focus-visible {
outline: 2px solid #5a9fd4;
outline-offset: 2px;
}
.scan-history-dialog-body { .scan-history-dialog-body {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
@ -346,6 +419,233 @@ body {
font-size: 0.95rem; font-size: 0.95rem;
} }
.is-hidden {
display: none !important;
}
.scan-history-analytics-view {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: auto;
}
.analytics-toolbar {
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.analytics-toolbar-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 10px;
}
.analytics-date-input {
padding: 6px 8px;
border-radius: 6px;
border: 1px solid #c5d4e8;
background: #fff;
color: #223a5f;
font-size: 0.88rem;
}
.analytics-date-sep {
color: #637898;
font-weight: 600;
}
.btn-analytics-export {
appearance: none;
border: 1px solid #1b5caa;
background: #fff;
color: #0d4a9c;
font-weight: 700;
font-size: 0.85rem;
padding: 7px 14px;
border-radius: 6px;
cursor: pointer;
white-space: nowrap;
}
.btn-analytics-export:hover {
background: #ebf4ff;
}
.analytics-summary-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
padding: 12px 14px 0;
}
.analytics-summary-card {
display: flex;
align-items: center;
gap: 10px;
background: #fff;
border: 1px solid #e2e8f2;
border-radius: 8px;
padding: 12px;
box-shadow: 0 1px 2px rgba(16, 36, 79, 0.05);
}
.analytics-summary-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 1.1rem;
flex-shrink: 0;
}
.analytics-icon-box {
background: #e8f0fa;
color: #0d4a9c;
}
.analytics-icon-week {
background: #e4f5e8;
color: #2f9950;
}
.analytics-icon-month {
background: #e8f0fa;
color: #0d4a9c;
}
.analytics-icon-total {
background: #e8f0fa;
color: #0d4a9c;
}
.analytics-summary-value {
font-size: 1.35rem;
font-weight: 800;
color: #0d3b7a;
line-height: 1.1;
}
.analytics-summary-label {
font-size: 0.82rem;
color: #637898;
font-weight: 600;
margin-top: 2px;
}
.analytics-charts-row {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 12px;
padding: 12px 14px 0;
}
.analytics-chart-panel {
background: #fff;
border: 1px solid #e2e8f2;
border-radius: 8px;
padding: 12px;
min-height: 280px;
}
.analytics-donut-panel {
display: flex;
flex-direction: column;
}
.analytics-panel-title {
margin: 0 0 10px;
font-size: 0.9rem;
font-weight: 800;
color: #163b72;
}
.analytics-bar-chart-wrap {
height: 220px;
position: relative;
}
.analytics-donut-wrap {
height: 180px;
position: relative;
flex: 1;
}
.analytics-legend {
list-style: none;
margin: 8px 0 0;
padding: 0;
font-size: 0.88rem;
color: #2a436a;
}
.analytics-legend li {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.analytics-legend-swatch {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
.analytics-legend-moved {
background: #3cae58;
}
.analytics-legend-pending {
background: #d5dde8;
}
.analytics-table-section {
padding: 12px 14px;
}
.analytics-table-wrap {
border: 1px solid #d7e1f0;
border-radius: 6px;
overflow: hidden;
max-height: 220px;
}
.analytics-table-wrap .table thead th {
position: sticky;
top: 0;
z-index: 1;
}
.scan-history-dialog-footer {
display: flex;
justify-content: flex-end;
padding: 10px 14px 14px;
border-top: 1px solid #e2e8f2;
margin-top: auto;
}
.btn-scan-history-dialog-close {
appearance: none;
border: 1px solid #c5d4e8;
background: #fff;
color: #2b456f;
font-weight: 700;
font-size: 0.88rem;
padding: 8px 18px;
border-radius: 6px;
cursor: pointer;
}
.btn-scan-history-dialog-close:hover {
background: #f3f6fb;
}
.history-split { .history-split {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -460,6 +760,10 @@ body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.kpi-grid {
grid-template-columns: 1fr;
}
.info-grid { .info-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -467,4 +771,16 @@ body {
.history-split { .history-split {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.available-cartons-value {
font-size: 2.75rem;
}
.analytics-summary-grid {
grid-template-columns: 1fr 1fr;
}
.analytics-charts-row {
grid-template-columns: 1fr;
}
} }

View File

@ -0,0 +1,398 @@
(() => {
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();
})();

View File

@ -16,6 +16,7 @@
const scanHistoryFilter = document.getElementById("scan-history-filter"); const scanHistoryFilter = document.getElementById("scan-history-filter");
const scanHistoryModalThead = document.getElementById("scan-history-modal-thead"); const scanHistoryModalThead = document.getElementById("scan-history-modal-thead");
const scanHistoryModalTbody = document.getElementById("scan-history-modal-tbody"); const scanHistoryModalTbody = document.getElementById("scan-history-modal-tbody");
const availableCartonsCountEl = document.getElementById("available-cartons-count");
const details = { const details = {
model: document.getElementById("detail-model"), model: document.getElementById("detail-model"),
@ -42,6 +43,7 @@
const incompleteNoSemicolonIdleMs = 420; const incompleteNoSemicolonIdleMs = 420;
const manualEntryMessage = "Manual entry is not allowed. Please scan or paste."; const manualEntryMessage = "Manual entry is not allowed. Please scan or paste.";
const completeQrPattern = /^[^;\r\n]+;[^;\r\n]+$/; const completeQrPattern = /^[^;\r\n]+;[^;\r\n]+$/;
const dashboardSummaryPollMs = 3000;
function clearAutoSubmitTimer() { function clearAutoSubmitTimer() {
if (autoSubmitTimer !== null) { if (autoSubmitTimer !== null) {
@ -133,6 +135,7 @@
const payload = await response.json(); const payload = await response.json();
renderSuccessfulHistory(payload.recentSuccessfulScans ?? []); renderSuccessfulHistory(payload.recentSuccessfulScans ?? []);
renderRejectedHistory(payload.recentRejectedScans ?? []); renderRejectedHistory(payload.recentRejectedScans ?? []);
updateAvailableCartonsCount(resolveAvailableCartons(payload));
} catch { } catch {
// Ignore — UI already shows manual rejection. // Ignore — UI already shows manual rejection.
} }
@ -282,6 +285,7 @@
renderStatus(result); renderStatus(result);
renderSuccessfulHistory(payload.recentSuccessfulScans ?? []); renderSuccessfulHistory(payload.recentSuccessfulScans ?? []);
renderRejectedHistory(payload.recentRejectedScans ?? []); renderRejectedHistory(payload.recentRejectedScans ?? []);
updateAvailableCartonsCount(resolveAvailableCartons(payload));
} catch { } catch {
setRejectedState("Unexpected communication error.", null); setRejectedState("Unexpected communication error.", null);
triggerFailureAlarm(); triggerFailureAlarm();
@ -393,6 +397,61 @@
}[char])); }[char]));
} }
function formatCartonCount(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return "—";
}
return Number(value).toLocaleString("en-US");
}
function resolveAvailableCartons(payload) {
if (!payload) {
return undefined;
}
if (payload.availableCartons !== undefined && payload.availableCartons !== null) {
return payload.availableCartons;
}
if (payload.availableCartonsCount !== undefined && payload.availableCartonsCount !== null) {
return payload.availableCartonsCount;
}
return undefined;
}
function updateAvailableCartonsCount(count) {
if (!availableCartonsCountEl || count === undefined || count === null) {
return;
}
availableCartonsCountEl.textContent = formatCartonCount(count);
}
async function refreshDashboardSummary() {
if (!availableCartonsCountEl) {
return;
}
try {
const response = await fetch("/api/verification/dashboard-summary");
if (!response.ok) {
return;
}
const payload = await response.json();
updateAvailableCartonsCount(resolveAvailableCartons(payload));
} catch {
// Keep last visible count; retry on next interval.
}
}
function startDashboardSummaryPolling() {
void refreshDashboardSummary();
window.setInterval(refreshDashboardSummary, dashboardSummaryPollMs);
}
function formatDisplayTime(utcValue) { function formatDisplayTime(utcValue) {
if (!utcValue) { if (!utcValue) {
return "-"; return "-";
@ -429,6 +488,7 @@
btnOpenScanHistory.addEventListener("click", async () => { btnOpenScanHistory.addEventListener("click", async () => {
scanHistoryFilter.value = "successful"; scanHistoryFilter.value = "successful";
window.avsMovementAnalytics?.setAnalyticsVisible(false);
await loadFullScanHistory("successful"); await loadFullScanHistory("successful");
if (typeof scanHistoryDialog.showModal === "function") { if (typeof scanHistoryDialog.showModal === "function") {
scanHistoryDialog.showModal(); scanHistoryDialog.showModal();
@ -437,14 +497,22 @@
}); });
btnCloseScanHistory.addEventListener("click", () => { btnCloseScanHistory.addEventListener("click", () => {
window.avsMovementAnalytics?.stopPolling();
scanHistoryDialog.close(); scanHistoryDialog.close();
}); });
scanHistoryFilter.addEventListener("change", async () => { scanHistoryFilter.addEventListener("change", async () => {
window.avsMovementAnalytics?.setAnalyticsVisible(false);
await loadFullScanHistory(scanHistoryFilter.value);
});
window.addEventListener("avs-history-show-records", async () => {
await loadFullScanHistory(scanHistoryFilter.value); await loadFullScanHistory(scanHistoryFilter.value);
}); });
scanHistoryDialog.addEventListener("close", () => { scanHistoryDialog.addEventListener("close", () => {
window.avsMovementAnalytics?.stopPolling();
window.avsMovementAnalytics?.setAnalyticsVisible(false);
qrInput.focus(); qrInput.focus();
}); });
} }
@ -537,6 +605,7 @@
} }
wireScanHistoryModal(); wireScanHistoryModal();
startDashboardSummaryPolling();
qrInput.focus(); qrInput.focus();
})(); })();