diff --git a/Views/Home/Index.cshtml b/Views/Home/Index.cshtml index c5cb581..565dc6d 100644 --- a/Views/Home/Index.cshtml +++ b/Views/Home/Index.cshtml @@ -16,55 +16,59 @@
-
-
-

🖶 SCAN QR CODE

-
- @Html.AntiForgeryToken() -
- - -
-
Format: modelnumber;uniquenumber — scans submit automatically.
-
-
- -
-

VERIFICATION STATUS

-
-
-
AWAITING SCAN
-
Waiting for QR input.
-
- ◴ Last Updated: - - +
+

🖶 SCAN QR CODE

+
+ @Html.AntiForgeryToken() +
+ +
-
+
Format: modelnumber;uniquenumber — scans submit automatically.
+
-
-
-

SCANNED DETAILS

-
- Model Number- - Unique Number- -
-
+
+

SCANNED DETAILS

+
+ Model Number- + Unique Number- +
+
+
-
-

🛡 VERIFICATION RESULT

-
- Exists in Carton Logs- - Shipment Marked- - Marked At- - Marked BySystem -
-
+
+
+

VERIFICATION STATUS

+
-
+
AWAITING SCAN
+
Waiting for QR input.
+
+ ◴ Last Updated: + - +
+
+ +
+

📦 AVAILABLE CARTONS

+
@(Model.AvailableCartonsCount.HasValue ? Model.AvailableCartonsCount.Value.ToString("N0") : "—")
+
Not moved to warehouse
+
+ +
+

🛡 VERIFICATION RESULT

+
+ Exists in Carton Logs- + Shipment Marked- + Marked At- + Marked BySystem +
@@ -151,24 +155,115 @@
- +
-

Scan history

+

Scan History & Analytics

-
+ +
+
-
- - - -
+ +
+
+ + + +
+
+
+ +
@@ -181,5 +276,7 @@ @section Scripts { + + } diff --git a/wwwroot/css/site.css b/wwwroot/css/site.css index 04c87fe..96114ee 100644 --- a/wwwroot/css/site.css +++ b/wwwroot/css/site.css @@ -62,10 +62,45 @@ body { gap: 10px; } -.left-column, -.right-column { +.kpi-grid { display: grid; + grid-template-columns: 1fr 1fr 1fr; 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 { @@ -259,6 +294,10 @@ body { color: #223a5f; } +.scan-history-dialog--wide { + max-width: min(1180px, 98vw); +} + .scan-history-dialog::backdrop { background: rgba(11, 47, 107, 0.5); } @@ -328,6 +367,40 @@ body { 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 { flex: 1; overflow: auto; @@ -346,6 +419,233 @@ body { 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 { display: grid; grid-template-columns: 1fr 1fr; @@ -460,6 +760,10 @@ body { grid-template-columns: 1fr; } + .kpi-grid { + grid-template-columns: 1fr; + } + .info-grid { grid-template-columns: 1fr; } @@ -467,4 +771,16 @@ body { .history-split { 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; + } } diff --git a/wwwroot/js/movement-analytics.js b/wwwroot/js/movement-analytics.js new file mode 100644 index 0000000..5224428 --- /dev/null +++ b/wwwroot/js/movement-analytics.js @@ -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 = ` +
  • Moved ${formatCount(moved)} (${movedPct}%)
  • +
  • Pending ${formatCount(pending)} (${pendingPct}%)
  • `; + } + } + + 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 = `No movement data for this date range.`; + return; + } + + tbody.innerHTML = ""; + dayDetails.forEach(row => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${escapeHtml(row.dateDisplay)} + ${formatCount(row.movedCartons)} + ${formatCount(row.successfulScans)} + ${formatCount(row.duplicateRejections)} + ${formatCount(row.manualEntryRejections)} + ${formatLastMovedTime(row.lastMovedAtUtc)}`; + 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(); +})(); diff --git a/wwwroot/js/verification-dashboard.js b/wwwroot/js/verification-dashboard.js index 4918377..3cfe203 100644 --- a/wwwroot/js/verification-dashboard.js +++ b/wwwroot/js/verification-dashboard.js @@ -16,6 +16,7 @@ const scanHistoryFilter = document.getElementById("scan-history-filter"); const scanHistoryModalThead = document.getElementById("scan-history-modal-thead"); const scanHistoryModalTbody = document.getElementById("scan-history-modal-tbody"); + const availableCartonsCountEl = document.getElementById("available-cartons-count"); const details = { model: document.getElementById("detail-model"), @@ -42,6 +43,7 @@ const incompleteNoSemicolonIdleMs = 420; const manualEntryMessage = "Manual entry is not allowed. Please scan or paste."; const completeQrPattern = /^[^;\r\n]+;[^;\r\n]+$/; + const dashboardSummaryPollMs = 3000; function clearAutoSubmitTimer() { if (autoSubmitTimer !== null) { @@ -133,6 +135,7 @@ const payload = await response.json(); renderSuccessfulHistory(payload.recentSuccessfulScans ?? []); renderRejectedHistory(payload.recentRejectedScans ?? []); + updateAvailableCartonsCount(resolveAvailableCartons(payload)); } catch { // Ignore — UI already shows manual rejection. } @@ -282,6 +285,7 @@ renderStatus(result); renderSuccessfulHistory(payload.recentSuccessfulScans ?? []); renderRejectedHistory(payload.recentRejectedScans ?? []); + updateAvailableCartonsCount(resolveAvailableCartons(payload)); } catch { setRejectedState("Unexpected communication error.", null); triggerFailureAlarm(); @@ -393,6 +397,61 @@ }[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) { if (!utcValue) { return "-"; @@ -429,6 +488,7 @@ btnOpenScanHistory.addEventListener("click", async () => { scanHistoryFilter.value = "successful"; + window.avsMovementAnalytics?.setAnalyticsVisible(false); await loadFullScanHistory("successful"); if (typeof scanHistoryDialog.showModal === "function") { scanHistoryDialog.showModal(); @@ -437,14 +497,22 @@ }); btnCloseScanHistory.addEventListener("click", () => { + window.avsMovementAnalytics?.stopPolling(); scanHistoryDialog.close(); }); scanHistoryFilter.addEventListener("change", async () => { + window.avsMovementAnalytics?.setAnalyticsVisible(false); + await loadFullScanHistory(scanHistoryFilter.value); + }); + + window.addEventListener("avs-history-show-records", async () => { await loadFullScanHistory(scanHistoryFilter.value); }); scanHistoryDialog.addEventListener("close", () => { + window.avsMovementAnalytics?.stopPolling(); + window.avsMovementAnalytics?.setAnalyticsVisible(false); qrInput.focus(); }); } @@ -537,6 +605,7 @@ } wireScanHistoryModal(); + startDashboardSummaryPolling(); qrInput.focus(); })();