AVS-Carton-Shipment-Verifier/wwwroot/js/verification-dashboard.js

642 lines
21 KiB
JavaScript

(() => {
const qrInput = document.getElementById("qr-input");
const statusCard = document.getElementById("status-card");
const statusMiniIcon = document.getElementById("status-mini-icon");
const statusMiniHeadline = document.getElementById("status-mini-headline");
const statusMiniMessage = document.getElementById("status-mini-message");
const lastUpdatedTime = document.getElementById("last-updated-time");
const historySuccessBody = document.getElementById("history-success-body");
const historySuccessEmpty = document.getElementById("history-success-empty");
const historyRejectedBody = document.getElementById("history-rejected-body");
const historyRejectedEmpty = document.getElementById("history-rejected-empty");
const scanHistoryDialog = document.getElementById("scan-history-dialog");
const btnOpenScanHistory = document.getElementById("btn-open-scan-history");
const btnCloseScanHistory = document.getElementById("scan-history-dialog-close");
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"),
unique: document.getElementById("detail-unique"),
exists: document.getElementById("detail-exists"),
marked: document.getElementById("detail-marked"),
time: document.getElementById("detail-time")
};
const antiForgeryToken = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
const failureAlarm = new Audio("/resources/Sound/alert.wav");
failureAlarm.preload = "auto";
let isSubmitting = false;
let autoSubmitTimer = null;
let incompleteIdleTimer = null;
/** Last time a non-paste text insertion was allowed (beforeinput insertText path). */
let lastBurstCharAt = 0;
/** True while paste handler is applying clipboard text (avoids timing false positives). */
let fromPasteHandler = false;
const maxInterKeyMs = 120;
const autoSubmitIdleMs = 72;
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) {
clearTimeout(autoSubmitTimer);
autoSubmitTimer = null;
}
}
function clearIncompleteIdleTimer() {
if (incompleteIdleTimer !== null) {
clearTimeout(incompleteIdleTimer);
incompleteIdleTimer = null;
}
}
function resetBurstTimer() {
lastBurstCharAt = 0;
}
function resetTypingHeuristics() {
resetBurstTimer();
clearIncompleteIdleTimer();
}
function normalizeQrValue(value) {
return (value ?? "").trim().replace(/\r?\n/g, "");
}
function scheduleAutoSubmit() {
clearAutoSubmitTimer();
autoSubmitTimer = window.setTimeout(() => {
autoSubmitTimer = null;
const v = normalizeQrValue(qrInput.value);
if (!completeQrPattern.test(v) || isSubmitting || fromPasteHandler) {
return;
}
void submitScan(v, "Scanner");
}, autoSubmitIdleMs);
}
function scheduleIncompleteIdleCheck() {
clearIncompleteIdleTimer();
incompleteIdleTimer = window.setTimeout(() => {
incompleteIdleTimer = null;
if (isSubmitting || fromPasteHandler) {
return;
}
const v = normalizeQrValue(qrInput.value);
if (!v || completeQrPattern.test(v)) {
return;
}
// Typing without a ';' (or abandoned partial) — not a scanner/paste flow.
if (!v.includes(";")) {
rejectManualTypingImmediate(v);
}
}, incompleteNoSemicolonIdleMs);
}
function rejectManualTypingImmediate(rawForServer) {
clearAutoSubmitTimer();
clearIncompleteIdleTimer();
const toLog = normalizeQrValue(rawForServer ?? qrInput.value);
qrInput.value = "";
resetBurstTimer();
setRejectedState(manualEntryMessage, null);
resetDetails();
triggerFailureAlarm();
void persistManualRejection(toLog);
}
async function persistManualRejection(qrValue) {
try {
const response = await fetch("/api/verification/scan", {
method: "POST",
headers: {
"Content-Type": "application/json",
"RequestVerificationToken": antiForgeryToken ?? ""
},
body: JSON.stringify({ qrValue, entryMethod: "Manual" })
});
if (!response.ok) {
return;
}
const payload = await response.json();
renderSuccessfulHistory(payload.recentSuccessfulScans ?? []);
renderRejectedHistory(payload.recentRejectedScans ?? []);
updateAvailableCartonsCount(resolveAvailableCartons(payload));
} catch {
// Ignore — UI already shows manual rejection.
}
}
function isInsertFromKeyboard(beforeInputEvent) {
const t = beforeInputEvent.inputType ?? "";
return t === "insertText" || t === "insertCompositionText";
}
qrInput.addEventListener("beforeinput", event => {
if (fromPasteHandler) {
return;
}
if (event.inputType === "insertFromPaste") {
return;
}
if (event.inputType?.startsWith("delete")) {
return;
}
if (!isInsertFromKeyboard(event)) {
return;
}
const now = Date.now();
if (lastBurstCharAt > 0 && now - lastBurstCharAt > maxInterKeyMs) {
event.preventDefault();
const bufferBeforeBlockedChar = normalizeQrValue(qrInput.value);
rejectManualTypingImmediate(bufferBeforeBlockedChar);
return;
}
lastBurstCharAt = now;
});
qrInput.addEventListener("keydown", event => {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
clearAutoSubmitTimer();
clearIncompleteIdleTimer();
const v = normalizeQrValue(qrInput.value);
if (!v || isSubmitting) {
return;
}
void submitScan(v, "Scanner");
});
qrInput.addEventListener("paste", event => {
const pastedValue = event.clipboardData?.getData("text");
if (!pastedValue) {
return;
}
event.preventDefault();
fromPasteHandler = true;
clearAutoSubmitTimer();
clearIncompleteIdleTimer();
resetBurstTimer();
const text = normalizeQrValue(pastedValue);
qrInput.value = text;
if (!text || isSubmitting) {
fromPasteHandler = false;
return;
}
void submitScan(text, "Paste").finally(() => {
fromPasteHandler = false;
});
});
qrInput.addEventListener("input", () => {
if (isSubmitting || fromPasteHandler) {
return;
}
const v = normalizeQrValue(qrInput.value);
if (!v) {
resetBurstTimer();
clearIncompleteIdleTimer();
clearAutoSubmitTimer();
return;
}
scheduleAutoSubmit();
scheduleIncompleteIdleCheck();
});
qrInput.addEventListener("keyup", event => {
if (isSubmitting || fromPasteHandler) {
return;
}
if (event.key === "Enter") {
return;
}
const v = normalizeQrValue(qrInput.value);
if (completeQrPattern.test(v)) {
scheduleAutoSubmit();
}
});
qrInput.addEventListener("focus", () => {
if (!qrInput.value) {
resetTypingHeuristics();
}
});
async function submitScan(qrValue, entryMethod) {
if (!qrValue || isSubmitting) {
return;
}
isSubmitting = true;
clearAutoSubmitTimer();
clearIncompleteIdleTimer();
resetBurstTimer();
try {
const response = await fetch("/api/verification/scan", {
method: "POST",
headers: {
"Content-Type": "application/json",
"RequestVerificationToken": antiForgeryToken ?? ""
},
body: JSON.stringify({ qrValue, entryMethod })
});
const payload = await response.json();
if (!response.ok) {
const fallbackMessage = "Verification service is unavailable.";
setRejectedState(payload.message ?? fallbackMessage, null);
resetDetails();
triggerFailureAlarm();
return;
}
const result = payload.result;
renderDetails(result);
renderStatus(result);
renderSuccessfulHistory(payload.recentSuccessfulScans ?? []);
renderRejectedHistory(payload.recentRejectedScans ?? []);
updateAvailableCartonsCount(resolveAvailableCartons(payload));
} catch {
setRejectedState("Unexpected communication error.", null);
triggerFailureAlarm();
resetDetails();
} finally {
qrInput.value = "";
resetTypingHeuristics();
qrInput.focus();
isSubmitting = false;
}
}
function renderStatus(result) {
if (result.isSuccessful) {
setStatusClasses("status-success");
statusMiniIcon.textContent = "\u2713";
statusMiniHeadline.textContent = "VERIFIED";
statusMiniMessage.textContent = result.message;
lastUpdatedTime.textContent = formatDisplayTime(result.processedAtUtc);
return;
}
setRejectedState(result.message, result.processedAtUtc);
triggerFailureAlarm();
}
function setRejectedState(message, processedAtUtc) {
setStatusClasses("status-failed");
statusMiniIcon.textContent = "!";
statusMiniHeadline.textContent = "REJECTED";
statusMiniMessage.textContent = message;
lastUpdatedTime.textContent = processedAtUtc ? formatDisplayTime(processedAtUtc) : "-";
}
function setStatusClasses(statusClass) {
statusCard.className = `panel panel-status ${statusClass}`;
}
function renderDetails(result) {
details.model.textContent = result.modelNumber || "-";
details.unique.textContent = result.uniqueNumber || "-";
details.exists.textContent = result.existsInSystem ? "YES" : "NO";
details.marked.textContent = result.shipmentMarked ? "TRUE" : "FALSE";
details.time.textContent = formatDisplayTime(result.processedAtUtc);
}
function resetDetails() {
details.model.textContent = "-";
details.unique.textContent = "-";
details.exists.textContent = "-";
details.marked.textContent = "-";
details.time.textContent = "-";
}
function renderSuccessfulHistory(records) {
historySuccessBody.innerHTML = "";
if (records.length === 0) {
historySuccessEmpty.classList.remove("is-hidden");
return;
}
historySuccessEmpty.classList.add("is-hidden");
records.forEach((record, index) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${index + 1}</td>
<td>${sanitize(record.modelNumber)}</td>
<td>${sanitize(record.uniqueNumber)}</td>
<td><span class="status-pill status-true">TRUE</span></td>
<td>${formatMovedToWarehouseAt(record.movedToWarehouseAtUtc)}</td>
<td>${formatDisplayTime(record.processedAtUtc)}</td>
<td>System</td>`;
historySuccessBody.appendChild(row);
});
}
function renderRejectedHistory(records) {
historyRejectedBody.innerHTML = "";
if (records.length === 0) {
historyRejectedEmpty.classList.remove("is-hidden");
return;
}
historyRejectedEmpty.classList.add("is-hidden");
records.forEach(record => {
const row = document.createElement("tr");
const model = record.modelNumber ? sanitize(record.modelNumber) : "—";
const unique = record.uniqueNumber ? sanitize(record.uniqueNumber) : "—";
row.innerHTML = `
<td>${formatDisplayTime(record.processedAtUtc)}</td>
<td>${sanitize(record.rawInputValue)}</td>
<td>${model}</td>
<td>${unique}</td>
<td>${sanitize(record.rejectionReason)}</td>`;
historyRejectedBody.appendChild(row);
});
}
function sanitize(value) {
return (value ?? "").replace(/[&<>\"']/g, char => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;"
}[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 "-";
}
const date = new Date(utcValue);
if (Number.isNaN(date.getTime())) {
return "-";
}
return date.toLocaleString("en-US", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true
});
}
function triggerFailureAlarm() {
failureAlarm.pause();
failureAlarm.currentTime = 0;
failureAlarm.play().catch(() => {
// Some browsers block audio until the scanner input is treated as a user gesture.
});
}
function wireScanHistoryModal() {
if (!scanHistoryDialog || !btnOpenScanHistory || !btnCloseScanHistory || !scanHistoryFilter || !scanHistoryModalThead || !scanHistoryModalTbody) {
return;
}
btnOpenScanHistory.addEventListener("click", async () => {
scanHistoryFilter.value = "successful";
window.avsMovementAnalytics?.setAnalyticsVisible(false);
await loadFullScanHistory("successful");
if (typeof scanHistoryDialog.showModal === "function") {
scanHistoryDialog.showModal();
scanHistoryFilter.focus();
}
});
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();
});
}
async function loadFullScanHistory(category) {
const normalized = category === "rejected" ? "rejected" : "successful";
const loadingColspan = normalized === "successful" ? 7 : 5;
scanHistoryModalThead.innerHTML = "";
scanHistoryModalTbody.innerHTML = `<tr><td colspan="6" class="scan-history-modal-empty">Loading…</td></tr>`;
try {
const response = await fetch(`/api/verification/history?category=${encodeURIComponent(normalized)}`);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
scanHistoryModalThead.innerHTML = "";
scanHistoryModalTbody.innerHTML = `<tr><td colspan="${loadingColspan}" class="scan-history-modal-empty">${sanitize(payload.message ?? "Unable to load history.")}</td></tr>`;
return;
}
const records = payload.records ?? [];
if (normalized === "successful") {
renderModalSuccessfulTable(records);
} else {
renderModalRejectedTable(records);
}
} catch {
scanHistoryModalThead.innerHTML = "";
scanHistoryModalTbody.innerHTML = `<tr><td colspan="${loadingColspan}" class="scan-history-modal-empty">Unable to load history.</td></tr>`;
}
}
function renderModalSuccessfulTable(records) {
scanHistoryModalThead.innerHTML = `
<tr>
<th>#</th>
<th>MODEL NUMBER</th>
<th>UNIQUE NUMBER</th>
<th>STATUS</th>
<th>MOVED TO WAREHOUSE AT</th>
<th>MARKED AT</th>
<th>MARKED BY</th>
</tr>`;
if (!records.length) {
scanHistoryModalTbody.innerHTML = `<tr><td colspan="7" class="scan-history-modal-empty">No successful records yet.</td></tr>`;
return;
}
scanHistoryModalTbody.innerHTML = "";
records.forEach((record, index) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${index + 1}</td>
<td>${sanitize(record.modelNumber)}</td>
<td>${sanitize(record.uniqueNumber)}</td>
<td><span class="status-pill status-true">TRUE</span></td>
<td>${formatMovedToWarehouseAt(record.movedToWarehouseAtUtc)}</td>
<td>${formatDisplayTime(record.processedAtUtc)}</td>
<td>System</td>`;
scanHistoryModalTbody.appendChild(row);
});
}
function formatMovedToWarehouseAt(utcValue) {
if (!utcValue) {
return "-";
}
const date = new Date(utcValue);
if (Number.isNaN(date.getTime())) {
return "-";
}
const parts = new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).formatToParts(date).reduce((acc, part) => {
acc[part.type] = part.value;
return acc;
}, {});
return `${parts.day}-${parts.month}-${parts.year} ${parts.hour}:${parts.minute}:${parts.second}`;
}
function renderModalRejectedTable(records) {
scanHistoryModalThead.innerHTML = `
<tr>
<th>TIME</th>
<th>SCANNED / INPUT</th>
<th>MODEL</th>
<th>UNIQUE</th>
<th>REASON</th>
</tr>`;
if (!records.length) {
scanHistoryModalTbody.innerHTML = `<tr><td colspan="5" class="scan-history-modal-empty">No rejected records yet.</td></tr>`;
return;
}
scanHistoryModalTbody.innerHTML = "";
records.forEach(record => {
const row = document.createElement("tr");
const model = record.modelNumber ? sanitize(record.modelNumber) : "—";
const unique = record.uniqueNumber ? sanitize(record.uniqueNumber) : "—";
row.innerHTML = `
<td>${formatDisplayTime(record.processedAtUtc)}</td>
<td>${sanitize(record.rawInputValue)}</td>
<td>${model}</td>
<td>${unique}</td>
<td>${sanitize(record.rejectionReason)}</td>`;
scanHistoryModalTbody.appendChild(row);
});
}
wireScanHistoryModal();
startDashboardSummaryPolling();
qrInput.focus();
})();