Update verification API and dashboard scan behavior

Updates dashboard/API flow to return recent scan history, handle rejected scans, auto-submit scanner input, and auto-submit pasted QR values without pressing Enter.
main
SYED MUSTUFA AHMED NAQVI 2026-05-15 11:45:09 +05:00
parent 1ffe45c76b
commit ae9a42fa70
4 changed files with 451 additions and 65 deletions

View File

@ -15,7 +15,8 @@ public sealed class HomeController(IScanHistoryStore scanHistoryStore) : Control
{ {
var model = new VerificationDashboardViewModel var model = new VerificationDashboardViewModel
{ {
RecentScans = await _scanHistoryStore.GetLastFiveAsync(cancellationToken) RecentSuccessfulScans = await _scanHistoryStore.GetLastFiveSuccessfulScansAsync(cancellationToken),
RecentRejectedScans = await _scanHistoryStore.GetLastFiveRejectedScansAsync(cancellationToken)
}; };
return View(model); return View(model);

View File

@ -25,11 +25,16 @@ public sealed class VerificationController(
try try
{ {
var verificationResult = await _shipmentVerificationService.VerifyQrAsync(request.QrValue, cancellationToken); var verificationResult = await _shipmentVerificationService.VerifyQrAsync(
request.QrValue,
request.EntryMethod ?? "Scanner",
cancellationToken);
return Ok(new return Ok(new
{ {
result = verificationResult, result = verificationResult,
recentScans = await _scanHistoryStore.GetLastFiveAsync(cancellationToken) recentSuccessfulScans = await _scanHistoryStore.GetLastFiveSuccessfulScansAsync(cancellationToken),
recentRejectedScans = await _scanHistoryStore.GetLastFiveRejectedScansAsync(cancellationToken)
}); });
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@ -46,5 +51,35 @@ public sealed class VerificationController(
}); });
} }
} }
/// <summary>
/// Returns all successful or all rejected scan records from SQLite (not limited to five).
/// </summary>
[HttpGet("history")]
public async Task<IActionResult> 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." });
}
}
} }

View File

@ -0,0 +1,13 @@
namespace AVSCartonShipmentVerifier.DTOs;
/// <summary>
/// User-facing rejection reasons (must match product copy).
/// </summary>
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";
}

View File

@ -5,8 +5,17 @@
const statusMiniHeadline = document.getElementById("status-mini-headline"); const statusMiniHeadline = document.getElementById("status-mini-headline");
const statusMiniMessage = document.getElementById("status-mini-message"); const statusMiniMessage = document.getElementById("status-mini-message");
const lastUpdatedTime = document.getElementById("last-updated-time"); const lastUpdatedTime = document.getElementById("last-updated-time");
const historyBody = document.querySelector("#history-table tbody"); const historySuccessBody = document.getElementById("history-success-body");
const historyEmpty = document.getElementById("history-empty"); 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 = { const details = {
model: document.getElementById("detail-model"), model: document.getElementById("detail-model"),
@ -19,45 +28,94 @@
const antiForgeryToken = document.querySelector('input[name="__RequestVerificationToken"]')?.value; const antiForgeryToken = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
const failureAlarm = new Audio("/resources/Sound/alert.wav"); const failureAlarm = new Audio("/resources/Sound/alert.wav");
failureAlarm.preload = "auto"; failureAlarm.preload = "auto";
let isSubmitting = false; 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 => { const maxInterKeyMs = 120;
if (event.key !== "Enter") { const autoSubmitIdleMs = 72;
return; const incompleteNoSemicolonIdleMs = 420;
} const manualEntryMessage = "Manual entry is not allowed. Please scan or paste.";
const completeQrPattern = /^[^;\r\n]+;[^;\r\n]+$/;
event.preventDefault(); function clearAutoSubmitTimer() {
await submitCurrentInput(); if (autoSubmitTimer !== null) {
}); clearTimeout(autoSubmitTimer);
autoSubmitTimer = null;
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;
} }
} }
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 { try {
const response = await fetch("/api/verification/scan", { const response = await fetch("/api/verification/scan", {
method: "POST", method: "POST",
@ -65,25 +123,174 @@
"Content-Type": "application/json", "Content-Type": "application/json",
"RequestVerificationToken": antiForgeryToken ?? "" "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(); const payload = await response.json();
if (!response.ok) { if (!response.ok) {
const fallbackMessage = "Verification service is unavailable."; const fallbackMessage = "Verification service is unavailable.";
setFailedState(payload.message ?? fallbackMessage); setRejectedState(payload.message ?? fallbackMessage, null);
resetDetails(); resetDetails();
triggerFailureAlarm();
return; return;
} }
const result = payload.result; const result = payload.result;
renderDetails(result); renderDetails(result);
renderStatus(result); renderStatus(result);
renderHistory(payload.recentScans ?? []); renderSuccessfulHistory(payload.recentSuccessfulScans ?? []);
renderRejectedHistory(payload.recentRejectedScans ?? []);
} catch { } catch {
setFailedState("Unexpected communication error."); setRejectedState("Unexpected communication error.", null);
triggerFailureAlarm(); triggerFailureAlarm();
resetDetails(); resetDetails();
} finally {
qrInput.value = "";
resetTypingHeuristics();
qrInput.focus();
isSubmitting = false;
} }
} }
@ -97,28 +304,16 @@
return; return;
} }
setFailedState(result.message); setRejectedState(result.message, result.processedAtUtc);
if (shouldTriggerFailureAlarm(result)) { triggerFailureAlarm();
triggerFailureAlarm();
}
} }
function shouldTriggerFailureAlarm(result) { function setRejectedState(message, processedAtUtc) {
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) {
setStatusClasses("status-failed"); setStatusClasses("status-failed");
statusMiniIcon.textContent = "!"; statusMiniIcon.textContent = "!";
statusMiniHeadline.textContent = "FAILED"; statusMiniHeadline.textContent = "REJECTED";
statusMiniMessage.textContent = message; statusMiniMessage.textContent = message;
lastUpdatedTime.textContent = "-"; lastUpdatedTime.textContent = processedAtUtc ? formatDisplayTime(processedAtUtc) : "-";
} }
function setStatusClasses(statusClass) { function setStatusClasses(statusClass) {
@ -141,26 +336,50 @@
details.time.textContent = "-"; details.time.textContent = "-";
} }
function renderHistory(records) { function renderSuccessfulHistory(records) {
historyBody.innerHTML = ""; historySuccessBody.innerHTML = "";
if (records.length === 0) { if (records.length === 0) {
historyEmpty.classList.remove("is-hidden"); historySuccessEmpty.classList.remove("is-hidden");
return; return;
} }
historyEmpty.classList.add("is-hidden"); historySuccessEmpty.classList.add("is-hidden");
records.forEach((record, index) => { records.forEach((record, index) => {
const row = document.createElement("tr"); const row = document.createElement("tr");
row.innerHTML = ` row.innerHTML = `
<td>${index + 1}</td> <td>${index + 1}</td>
<td>${sanitize(record.modelNumber)}</td> <td>${sanitize(record.modelNumber)}</td>
<td>${sanitize(record.uniqueNumber)}</td> <td>${sanitize(record.uniqueNumber)}</td>
<td><span class="status-pill ${record.shipmentMarked ? "status-true" : "status-false"}">${record.shipmentMarked ? "TRUE" : "FALSE"}</span></td> <td><span class="status-pill status-true">TRUE</span></td>
<td>${formatDisplayTime(record.processedAtUtc)}</td> <td>${formatDisplayTime(record.processedAtUtc)}</td>
<td>System</td>`; <td>System</td>`;
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 = `
<td>${formatDisplayTime(record.processedAtUtc)}</td>
<td>${sanitize(record.rawInputValue)}</td>
<td>${model}</td>
<td>${unique}</td>
<td>${sanitize(record.rejectionReason)}</td>`;
historyRejectedBody.appendChild(row);
}); });
} }
@ -202,4 +421,122 @@
// Some browsers block audio until the scanner input is treated as a user gesture. // 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 = `<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();
qrInput.focus();
})(); })();