612 lines
20 KiB
JavaScript
612 lines
20 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>${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 => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
"\"": """,
|
|
"'": "'"
|
|
}[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";
|
|
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="6" 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="6" 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>MARKED AT</th>
|
|
<th>MARKED BY</th>
|
|
</tr>`;
|
|
|
|
if (!records.length) {
|
|
scanHistoryModalTbody.innerHTML = `<tr><td colspan="6" 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>${formatDisplayTime(record.processedAtUtc)}</td>
|
|
<td>System</td>`;
|
|
scanHistoryModalTbody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
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();
|
|
})();
|