Add movement analytics dashboard UI
Adds/updates the dashboard UI, styling, analytics chart/table behavior, and client-side scan/dashboard interactions.main
parent
2430700dbf
commit
4c647569cd
|
|
@ -16,7 +16,6 @@
|
|||
|
||||
<main class="dashboard-main">
|
||||
<section class="top-grid">
|
||||
<section class="left-column">
|
||||
<section class="panel panel-scan">
|
||||
<h2 class="section-title"><span class="section-icon">🖶</span> SCAN QR CODE</h2>
|
||||
<form id="scan-form" autocomplete="off" novalidate onsubmit="return false;">
|
||||
|
|
@ -35,6 +34,16 @@
|
|||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-scanned-details">
|
||||
<h2 class="section-title"><span class="section-icon">ⓘ</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">
|
||||
<h2 class="section-title"><span class="section-icon">∿</span> VERIFICATION STATUS</h2>
|
||||
<div class="status-mini-icon" id="status-mini-icon">-</div>
|
||||
|
|
@ -45,18 +54,14 @@
|
|||
<strong id="last-updated-time">-</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-available-cartons" aria-live="polite">
|
||||
<h2 class="section-title section-title-center"><span class="section-icon">📦</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 class="right-column">
|
||||
<section class="panel">
|
||||
<h2 class="section-title"><span class="section-icon">ⓘ</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">
|
||||
<section class="panel panel-verification-result">
|
||||
<h2 class="section-title"><span class="section-icon">🛡</span> VERIFICATION RESULT</h2>
|
||||
<div class="info-grid">
|
||||
<span>Exists in Carton Logs</span><strong id="detail-exists">-</strong>
|
||||
|
|
@ -66,7 +71,6 @@
|
|||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-history">
|
||||
<div class="panel-history-head">
|
||||
|
|
@ -151,19 +155,23 @@
|
|||
</div>
|
||||
</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">
|
||||
<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 & Analytics</h2>
|
||||
<button type="button" class="scan-history-dialog-close" id="scan-history-dialog-close" aria-label="Close">×</button>
|
||||
</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>
|
||||
<select id="scan-history-filter" class="scan-history-filter" aria-label="Record type">
|
||||
<option value="successful">Successful Records</option>
|
||||
<option value="rejected">Rejected Records</option>
|
||||
</select>
|
||||
<button type="button" class="btn-history-mode" id="btn-movement-analytics">Movement Analytics</button>
|
||||
</div>
|
||||
|
||||
<div id="scan-history-records-view">
|
||||
<div class="scan-history-dialog-body table-responsive">
|
||||
<table class="table table-sm align-middle" id="scan-history-modal-table">
|
||||
<thead id="scan-history-modal-thead"></thead>
|
||||
|
|
@ -171,6 +179,93 @@
|
|||
</table>
|
||||
</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">📦</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">📅</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">📅</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">📦</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>
|
||||
</main>
|
||||
|
||||
|
|
@ -181,5 +276,7 @@
|
|||
</footer>
|
||||
|
||||
@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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'"
|
||||
}[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();
|
||||
})();
|
||||
|
|
@ -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();
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in New Issue