GetFullHistory([FromQuery] string category = "successful", CancellationToken cancellationToken = default)
+ {
+ var normalized = category.Trim().ToLowerInvariant();
+ try
+ {
+ if (normalized == "successful")
+ {
+ var records = await _scanHistoryStore.GetAllSuccessfulScansAsync(cancellationToken);
+ return Ok(new { category = "successful", records });
+ }
+
+ if (normalized == "rejected")
+ {
+ var records = await _scanHistoryStore.GetAllRejectedScansAsync(cancellationToken);
+ return Ok(new { category = "rejected", records });
+ }
+
+ return BadRequest(new { message = "Invalid category. Use 'successful' or 'rejected'." });
+ }
+ catch (Exception exception)
+ {
+ _logger.LogError(exception, "Unexpected error while loading scan history.");
+ return StatusCode(StatusCodes.Status500InternalServerError, new { message = "Unable to load scan history." });
+ }
+ }
}
diff --git a/DTOs/VerificationRejectionReasons.cs b/DTOs/VerificationRejectionReasons.cs
new file mode 100644
index 0000000..9ebf8e3
--- /dev/null
+++ b/DTOs/VerificationRejectionReasons.cs
@@ -0,0 +1,13 @@
+namespace AVSCartonShipmentVerifier.DTOs;
+
+///
+/// User-facing rejection reasons (must match product copy).
+///
+public static class VerificationRejectionReasons
+{
+ public const string InvalidFormat = "Invalid format";
+ public const string CartonDoesNotExist = "Carton does not exist";
+ public const string AlreadyTransferredDuplicateScan = "Already transferred / duplicate scan";
+ public const string ManualEntryNotAllowed = "Manual entry is not allowed. Please scan or paste.";
+ public const string DatabaseOrSystemError = "Database error / system error";
+}
diff --git a/wwwroot/js/verification-dashboard.js b/wwwroot/js/verification-dashboard.js
index 0a18028..4918377 100644
--- a/wwwroot/js/verification-dashboard.js
+++ b/wwwroot/js/verification-dashboard.js
@@ -5,8 +5,17 @@
const statusMiniHeadline = document.getElementById("status-mini-headline");
const statusMiniMessage = document.getElementById("status-mini-message");
const lastUpdatedTime = document.getElementById("last-updated-time");
- const historyBody = document.querySelector("#history-table tbody");
- const historyEmpty = document.getElementById("history-empty");
+ 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 details = {
model: document.getElementById("detail-model"),
@@ -19,45 +28,94 @@
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;
- qrInput.addEventListener("keydown", async event => {
- if (event.key !== "Enter") {
- return;
- }
+ 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]+$/;
- event.preventDefault();
- await submitCurrentInput();
- });
-
- qrInput.addEventListener("paste", async event => {
- const pastedValue = event.clipboardData?.getData("text")?.trim();
- if (!pastedValue) {
- return;
- }
-
- event.preventDefault();
- qrInput.value = pastedValue;
- await submitCurrentInput();
- });
-
- async function submitCurrentInput() {
- const qrValue = qrInput.value.trim();
- if (!qrValue || isSubmitting) {
- return;
- }
-
- isSubmitting = true;
- try {
- await submitScan(qrValue);
- qrInput.value = "";
- qrInput.focus();
- } finally {
- isSubmitting = false;
+ function clearAutoSubmitTimer() {
+ if (autoSubmitTimer !== null) {
+ clearTimeout(autoSubmitTimer);
+ autoSubmitTimer = null;
}
}
- async function submitScan(qrValue) {
+ 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",
@@ -65,25 +123,174 @@
"Content-Type": "application/json",
"RequestVerificationToken": antiForgeryToken ?? ""
},
- body: JSON.stringify({ qrValue })
+ body: JSON.stringify({ qrValue, entryMethod: "Manual" })
+ });
+
+ if (!response.ok) {
+ return;
+ }
+
+ const payload = await response.json();
+ renderSuccessfulHistory(payload.recentSuccessfulScans ?? []);
+ renderRejectedHistory(payload.recentRejectedScans ?? []);
+ } 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.";
- setFailedState(payload.message ?? fallbackMessage);
+ setRejectedState(payload.message ?? fallbackMessage, null);
resetDetails();
+ triggerFailureAlarm();
return;
}
const result = payload.result;
renderDetails(result);
renderStatus(result);
- renderHistory(payload.recentScans ?? []);
+ renderSuccessfulHistory(payload.recentSuccessfulScans ?? []);
+ renderRejectedHistory(payload.recentRejectedScans ?? []);
} catch {
- setFailedState("Unexpected communication error.");
+ setRejectedState("Unexpected communication error.", null);
triggerFailureAlarm();
resetDetails();
+ } finally {
+ qrInput.value = "";
+ resetTypingHeuristics();
+ qrInput.focus();
+ isSubmitting = false;
}
}
@@ -97,28 +304,16 @@
return;
}
- setFailedState(result.message);
- if (shouldTriggerFailureAlarm(result)) {
- triggerFailureAlarm();
- }
+ setRejectedState(result.message, result.processedAtUtc);
+ triggerFailureAlarm();
}
- function shouldTriggerFailureAlarm(result) {
- const message = (result.message ?? "").toLowerCase();
- return !result.existsInSystem
- && (
- message.includes("invalid qr format")
- || message.includes("qr value is required")
- || message.includes("not found")
- );
- }
-
- function setFailedState(message) {
+ function setRejectedState(message, processedAtUtc) {
setStatusClasses("status-failed");
statusMiniIcon.textContent = "!";
- statusMiniHeadline.textContent = "FAILED";
+ statusMiniHeadline.textContent = "REJECTED";
statusMiniMessage.textContent = message;
- lastUpdatedTime.textContent = "-";
+ lastUpdatedTime.textContent = processedAtUtc ? formatDisplayTime(processedAtUtc) : "-";
}
function setStatusClasses(statusClass) {
@@ -141,26 +336,50 @@
details.time.textContent = "-";
}
- function renderHistory(records) {
- historyBody.innerHTML = "";
+ function renderSuccessfulHistory(records) {
+ historySuccessBody.innerHTML = "";
if (records.length === 0) {
- historyEmpty.classList.remove("is-hidden");
+ historySuccessEmpty.classList.remove("is-hidden");
return;
}
- historyEmpty.classList.add("is-hidden");
+ historySuccessEmpty.classList.add("is-hidden");
records.forEach((record, index) => {
const row = document.createElement("tr");
row.innerHTML = `
| ${index + 1} |
${sanitize(record.modelNumber)} |
${sanitize(record.uniqueNumber)} |
- ${record.shipmentMarked ? "TRUE" : "FALSE"} |
+ TRUE |
${formatDisplayTime(record.processedAtUtc)} |
System | `;
- historyBody.appendChild(row);
+ 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 = `
+ ${formatDisplayTime(record.processedAtUtc)} |
+ ${sanitize(record.rawInputValue)} |
+ ${model} |
+ ${unique} |
+ ${sanitize(record.rejectionReason)} | `;
+
+ historyRejectedBody.appendChild(row);
});
}
@@ -202,4 +421,122 @@
// 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";
+ await loadFullScanHistory("successful");
+ if (typeof scanHistoryDialog.showModal === "function") {
+ scanHistoryDialog.showModal();
+ scanHistoryFilter.focus();
+ }
+ });
+
+ btnCloseScanHistory.addEventListener("click", () => {
+ scanHistoryDialog.close();
+ });
+
+ scanHistoryFilter.addEventListener("change", async () => {
+ await loadFullScanHistory(scanHistoryFilter.value);
+ });
+
+ scanHistoryDialog.addEventListener("close", () => {
+ qrInput.focus();
+ });
+ }
+
+ async function loadFullScanHistory(category) {
+ const normalized = category === "rejected" ? "rejected" : "successful";
+ scanHistoryModalThead.innerHTML = "";
+ scanHistoryModalTbody.innerHTML = `| Loading… |
`;
+
+ try {
+ const response = await fetch(`/api/verification/history?category=${encodeURIComponent(normalized)}`);
+ const payload = await response.json().catch(() => ({}));
+
+ if (!response.ok) {
+ scanHistoryModalThead.innerHTML = "";
+ scanHistoryModalTbody.innerHTML = `| ${sanitize(payload.message ?? "Unable to load history.")} |
`;
+ return;
+ }
+
+ const records = payload.records ?? [];
+ if (normalized === "successful") {
+ renderModalSuccessfulTable(records);
+ } else {
+ renderModalRejectedTable(records);
+ }
+ } catch {
+ scanHistoryModalThead.innerHTML = "";
+ scanHistoryModalTbody.innerHTML = `| Unable to load history. |
`;
+ }
+ }
+
+ function renderModalSuccessfulTable(records) {
+ scanHistoryModalThead.innerHTML = `
+
+ | # |
+ MODEL NUMBER |
+ UNIQUE NUMBER |
+ STATUS |
+ MARKED AT |
+ MARKED BY |
+
`;
+
+ if (!records.length) {
+ scanHistoryModalTbody.innerHTML = `| No successful records yet. |
`;
+ return;
+ }
+
+ scanHistoryModalTbody.innerHTML = "";
+ records.forEach((record, index) => {
+ const row = document.createElement("tr");
+ row.innerHTML = `
+ ${index + 1} |
+ ${sanitize(record.modelNumber)} |
+ ${sanitize(record.uniqueNumber)} |
+ TRUE |
+ ${formatDisplayTime(record.processedAtUtc)} |
+ System | `;
+ scanHistoryModalTbody.appendChild(row);
+ });
+ }
+
+ function renderModalRejectedTable(records) {
+ scanHistoryModalThead.innerHTML = `
+
+ | TIME |
+ SCANNED / INPUT |
+ MODEL |
+ UNIQUE |
+ REASON |
+
`;
+
+ if (!records.length) {
+ scanHistoryModalTbody.innerHTML = `| No rejected records yet. |
`;
+ 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 = `
+ ${formatDisplayTime(record.processedAtUtc)} |
+ ${sanitize(record.rawInputValue)} |
+ ${model} |
+ ${unique} |
+ ${sanitize(record.rejectionReason)} | `;
+ scanHistoryModalTbody.appendChild(row);
+ });
+ }
+
+ wireScanHistoryModal();
+
+ qrInput.focus();
})();