diff --git a/Controllers/HomeController.cs b/Controllers/HomeController.cs index cfbe9e7..2449078 100644 --- a/Controllers/HomeController.cs +++ b/Controllers/HomeController.cs @@ -15,7 +15,8 @@ public sealed class HomeController(IScanHistoryStore scanHistoryStore) : Control { var model = new VerificationDashboardViewModel { - RecentScans = await _scanHistoryStore.GetLastFiveAsync(cancellationToken) + RecentSuccessfulScans = await _scanHistoryStore.GetLastFiveSuccessfulScansAsync(cancellationToken), + RecentRejectedScans = await _scanHistoryStore.GetLastFiveRejectedScansAsync(cancellationToken) }; return View(model); diff --git a/Controllers/VerificationController.cs b/Controllers/VerificationController.cs index 43fa5a7..9a8d76b 100644 --- a/Controllers/VerificationController.cs +++ b/Controllers/VerificationController.cs @@ -25,11 +25,16 @@ public sealed class VerificationController( try { - var verificationResult = await _shipmentVerificationService.VerifyQrAsync(request.QrValue, cancellationToken); + var verificationResult = await _shipmentVerificationService.VerifyQrAsync( + request.QrValue, + request.EntryMethod ?? "Scanner", + cancellationToken); + return Ok(new { result = verificationResult, - recentScans = await _scanHistoryStore.GetLastFiveAsync(cancellationToken) + recentSuccessfulScans = await _scanHistoryStore.GetLastFiveSuccessfulScansAsync(cancellationToken), + recentRejectedScans = await _scanHistoryStore.GetLastFiveRejectedScansAsync(cancellationToken) }); } catch (OperationCanceledException) @@ -46,5 +51,35 @@ public sealed class VerificationController( }); } } + + /// + /// Returns all successful or all rejected scan records from SQLite (not limited to five). + /// + [HttpGet("history")] + public async Task 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(); })();