WFDASH-WEBAPP/src/app/scan/page.tsx
Dave 80608602ad fix(scan): the QR was being downscaled below its own resolution
Scanning felt like it never resolved. It was not slow — it was being
downscaled into unreadability.

The printed QR is 28pt on a 210mm page: 4.7% of the page width. The loop
down-scaled the camera frame to 640px wide before decoding, so a phone
pointed at a full A4 sheet rendered that QR at ~30px total, about 1 pixel
per module. Measured against the real rendered page, jsQR cannot read it
at any crop size:

    cap    px/module   whole frame        bottom-45% ROI
     640      1.04      MISS               MISS
    1280      2.08      miss               miss
    1600      2.60      HIT  69ms          HIT  28ms
    1920      3.12      HIT  98ms          HIT  39ms
    2560      4.16      HIT 171ms          HIT  39ms

Above ~2.5px per module it is reliable, so the cap is now 1920. No amount
of speeding up the loop could have fixed this; the pixels were not there.

  - requestAnimationFrame instead of a 120ms setInterval. rAF fires
    straight after a painted frame, so the decoder gets a fresh one
    rather than whichever frame happened to be current at the tick; it
    also removes the 120ms floor on every detection and lets a
    readyState stall recover on the next frame instead of a timer tick.
  - Two regions instead of one. The delivery-note QR sits in the bottom
    band of the page, so that band is scanned every frame at roughly a
    third of the cost of the full frame (39ms vs 98ms). The full frame
    runs every 6th as a safety net for a tilted page. It is an
    accelerator, not a restriction.
  - Native BarcodeDetector is now EXCLUSIVE when present. It used to
    fall through to jsQR on a miss "because jsQR sometimes succeeds
    where native blurs", which doubled the cost on every miss — the
    common case. The native detector is Google's own ML stack; when the
    browser ships it, use it alone.
  - Screen wake lock while scanning, re-taken on visibilitychange
    because the browser drops it whenever the tab hides. Otherwise the
    phone locks mid-queue.
  - 3s debounce per code, and the camera keeps running after a hit, so a
    stack of notes can be worked through without a tap per sheet.

The viewfinder is now ONE <video> mounted for the life of the page,
resized when a note loads. A second copy in the note branch would have
unmounted the first — and since a MediaStream is attached to the
ELEMENT, the replacement video would have come up blank while the stream
kept running into nothing.

Four new tests pin the resolution floor, including one that documents
that a full A4 page framed at 640px is UNREADABLE, so nobody lowers
MAX_SCAN_EDGE back to 640 thinking it is just a performance knob.
2026-10-04 13:51:44 +02:00

637 lines
23 KiB
TypeScript

"use client";
/**
* /scan — delivery-note scanner.
*
* Standalone (outside the `(app)` route group) so it gets a clean
* full-bleed layout: a scanner has no business in a CRM sidebar, and
* the operator using it is often holding a phone.
*
* Flow: point the camera at the QR printed on a delivery note → the
* note is resolved → offer the three things you actually do at the
* counter: reprint, photograph the signed copy, or move it toward
* invoicing.
*
* The QR carries the human number (`DN26100401`), so the first call is
* `resolve`, which maps number → PB id. Everything after that speaks
* ids, which is what every existing route already takes.
*
* STATUS LADDER — the actions offered depend on where the note sits.
* `mark-invoiced` hard-refuses anything that isn't `po_received`, and
* `customer-po` refuses a note that isn't `sent`/`loaded`. So rather
* than offering a button that 500s, each step only appears when it is
* legal, and the card shows which one comes next:
*
* draft ──load──▶ loaded ──PO──▶ po_received ──invoice──▶ invoiced
* └─(sent via quote)──▶ sent ──PO──▶ po_received ──▶ ...
*
* `loaded` is used rather than `sent` because `draft → sent` means
* "create a quote and email it" — a side effect a warehouse scan must
* never trigger silently. `load-dn` is the honest "the van has this
* paperwork" step.
*/
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Camera,
CameraOff,
CheckCircle2,
FileText,
Loader2,
QrCode,
RefreshCw,
Upload,
X,
} from "lucide-react";
import getPocketBase from "@/lib/pocketbase";
import {
detectScannerSupport,
parseDeliveryNoteNumber,
scanVideoFrames,
type QrDetectorKind,
} from "@/lib/scan/qr-detect";
type Resolved = {
id: string;
delivery_note_number: string;
status: string;
customer_name: string;
company_name: string;
customer_po: string;
delivery_date: string;
signed_copy: string;
item_count: number;
};
type Toast = { kind: "ok" | "err"; text: string } | null;
/**
* Minimal shape of a Screen Wake Lock sentinel. The DOM lib types
* this, but declaring it locally keeps the page compiling even where
* the lib is older than the API and the types are absent.
*/
type WakeLockSentinelLike = {
release: () => Promise<void>;
addEventListener?: (type: "release", listener: () => void) => void;
};
/**
* How long the same delivery note is ignored after a hit. The camera
* stays pointed at the paper, so without this the same code re-fires
* every frame.
*/
const DEBOUNCE_MS = 3000;
const STATUS_COPY: Record<string, string> = {
draft: "Draft — not yet loaded",
sent: "Sent to customer",
loaded: "Loaded — with the driver",
po_received: "Customer PO received",
invoiced: "Invoiced",
completed: "Completed",
cancelled: "Cancelled",
};
export default function ScanPage() {
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const streamRef = React.useRef<MediaStream | null>(null);
const abortRef = React.useRef<AbortController | null>(null);
const fileRef = React.useRef<HTMLInputElement | null>(null);
// Held while the camera is live so the phone does not sleep mid-scan,
// and re-taken when the tab comes back to the foreground (the
// browser drops the lock whenever the page is hidden).
const wakeRef = React.useRef<WakeLockSentinelLike | null>(null);
const [support, setSupport] = React.useState<{
kind: QrDetectorKind;
camera: boolean;
secureContext: boolean;
} | null>(null);
const [cameraOn, setCameraOn] = React.useState(false);
const [manual, setManual] = React.useState("");
const [note, setNote] = React.useState<Resolved | null>(null);
const [busy, setBusy] = React.useState<string | null>(null);
const [toast, setToast] = React.useState<Toast>(null);
// Last code we accepted, and when — so holding the camera on one
// note for 3s does not fire the lookup over and over.
const lastCodeRef = React.useRef<{ code: string; at: number } | null>(null);
const [poDraft, setPoDraft] = React.useState("");
const [showPoBox, setShowPoBox] = React.useState(false);
React.useEffect(() => {
setSupport(detectScannerSupport());
}, []);
// Stop the camera on unmount. Leaving a getUserMedia stream running
// keeps the camera light on after the operator navigates away.
React.useEffect(
() => () => {
abortRef.current?.abort();
streamRef.current?.getTracks().forEach((t) => t.stop());
void wakeRef.current?.release();
},
[],
);
const flash = React.useCallback((kind: "ok" | "err", text: string) => {
setToast({ kind, text });
window.setTimeout(() => setToast(null), 6000);
}, []);
const requestWakeLock = React.useCallback(async () => {
try {
const nav = navigator as Navigator & {
wakeLock?: { request: (t: "screen") => Promise<WakeLockSentinelLike> };
};
if (!nav.wakeLock) return;
const sentinel = await nav.wakeLock.request("screen");
wakeRef.current = sentinel;
// Released automatically when the tab hides; nothing to clean up
// beyond dropping our reference.
sentinel.addEventListener?.("release", () => {
if (wakeRef.current === sentinel) wakeRef.current = null;
});
} catch {
// Denied or unsupported — the scan still works, the screen may
// just dim. Not worth interrupting the operator for.
}
}, []);
const stopCamera = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
setCameraOn(false);
void wakeRef.current?.release();
wakeRef.current = null;
}, []);
// Browsers drop the screen wake lock whenever the page is hidden, so
// take it again on the way back in — otherwise the phone locks
// mid-queue at the warehouse.
React.useEffect(() => {
const onVisible = () => {
if (document.visibilityState === "visible" && streamRef.current) {
void requestWakeLock();
}
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, [requestWakeLock]);
const api = React.useCallback(
async (path: string, init?: RequestInit) => {
const token = getPocketBase().authStore.token;
const res = await fetch(path, {
...init,
headers: {
...(init?.headers ?? {}),
Authorization: token,
},
});
const text = await res.text();
let body: unknown = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = null;
}
if (!res.ok) {
const msg =
body && typeof body === "object" && "error" in body && typeof body.error === "string"
? body.error
: `HTTP ${res.status}`;
throw new Error(msg);
}
return body as unknown;
},
[],
);
const lookup = React.useCallback(
async (number: string) => {
const now = Date.now();
const last = lastCodeRef.current;
if (last && last.code === number && now - last.at < DEBOUNCE_MS) return;
lastCodeRef.current = { code: number, at: now };
setBusy("lookup");
try {
const data = await api(
`/api/v1/delivery-notes/resolve?number=${encodeURIComponent(number)}`,
);
setNote(data as Resolved);
setPoDraft((data as Resolved).customer_po ?? "");
setShowPoBox(false);
// The camera deliberately KEEPS RUNNING. The operator often has
// a stack of notes; scanning the next one should not need a
// tap. The note card renders over the viewfinder, and
// "Scan another" is no longer needed — but a stale frame of
// the same paper must not re-fire, hence the debounce above.
flash("ok", `${number} loaded`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Lookup failed";
setNote(null);
flash("err", msg);
} finally {
setBusy(null);
}
},
[api, flash],
);
const startCamera = React.useCallback(async () => {
setToast(null);
if (!support?.camera) {
flash("err", "This device has no camera available to the browser.");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "environment" } },
audio: false,
});
streamRef.current = stream;
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas) return;
video.srcObject = stream;
video.setAttribute("playsinline", "true");
await video.play();
setCameraOn(true);
void requestWakeLock();
const controller = new AbortController();
abortRef.current = controller;
void scanVideoFrames(
video,
canvas,
(raw) => {
const dn = parseDeliveryNoteNumber(raw);
if (dn) void lookup(dn);
else flash("err", `Scanned a code that isn't a delivery note: ${raw.slice(0, 40)}`);
},
controller.signal,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
flash("err", `Camera failed to start: ${msg}`);
}
}, [support, flash, lookup]);
const runAction = React.useCallback(
async (key: string, fn: () => Promise<void>) => {
setBusy(key);
try {
await fn();
} catch (err) {
flash("err", err instanceof Error ? err.message : "Action failed");
} finally {
setBusy(null);
}
},
[flash],
);
const status = note?.status ?? "";
// Ladder helpers — each mirrors the server's own guards so the UI
// never offers a button the API will refuse.
const canLoad = status === "draft" || status === "sent";
const canPo = status === "sent" || status === "loaded";
const canInvoice = status === "po_received";
const isDead = status === "cancelled" || status === "completed";
const nextStep = canLoad
? "Load this note for the driver"
: canPo
? "Capture the customer's PO number"
: canInvoice
? "Ready to send to Invoicing"
: isDead
? "This note is closed"
: "No action needed right now";
return (
<main className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col gap-5 px-4 py-6">
<header className="flex items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">Delivery note scanner</h1>
<p className="text-sm text-muted-foreground">
Scan the QR on a printed delivery note
</p>
</div>
{note ? (
<Button variant="outline" size="sm" onClick={() => { setNote(null); setPoDraft(""); }}>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
Scan another
</Button>
) : null}
</header>
{/*
ONE <video> for the whole component, mounted for the entire
life of the page.
It used to be rendered inside the scanner branch only, with a
second copy in the note branch. That is a trap: switching
branches unmounts the old element and mounts a new one, and the
MediaStream is attached to the ELEMENT — the replacement video
comes up blank while the stream keeps running into nothing.
So the viewfinder lives here and only its size changes.
*/}
<div
className={
note
? "relative aspect-[4/3] w-full overflow-hidden rounded-xl border bg-black"
: "relative aspect-square w-full overflow-hidden rounded-xl border bg-black"
}
>
<video
ref={videoRef}
className="h-full w-full object-cover"
muted
playsInline
data-testid="scan-video"
/>
{!cameraOn ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
<QrCode className="h-10 w-10 text-white/60" />
<p className="text-sm text-white/80">
{support?.camera
? note
? "Camera off — press Start camera to scan the next note"
: "Camera is off"
: "This browser will not give the page a camera"}
</p>
</div>
) : null}
<div className="pointer-events-none absolute inset-[18%] rounded-xl border-2 border-white/70" />
</div>
<canvas ref={canvasRef} className="hidden" />
{/* ---- the note card, or the scanner controls ---- */}
{note ? (
<section className="flex flex-col gap-4" data-testid="scan-note">
<div className="rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-mono text-lg font-semibold">{note.delivery_note_number}</p>
<p className="text-sm text-muted-foreground">
{note.company_name || note.customer_name || "—"}
</p>
{note.customer_name ? (
<p className="text-sm text-muted-foreground">{note.customer_name}</p>
) : null}
</div>
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold">
{STATUS_COPY[status] ?? status}
</span>
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<div>
<dt className="text-xs uppercase tracking-wide text-muted-foreground">Items</dt>
<dd className="font-medium">{note.item_count}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-muted-foreground">Customer PO</dt>
<dd className="font-medium">{note.customer_po || "—"}</dd>
</div>
{note.signed_copy ? (
<div className="col-span-2">
<dt className="text-xs uppercase tracking-wide text-muted-foreground">
Signed copy
</dt>
<dd className="truncate font-medium text-emerald-700">{note.signed_copy}</dd>
</div>
) : null}
</dl>
</div>
<p className="text-sm text-muted-foreground">{nextStep}</p>
<div className="flex flex-col gap-2">
<Button
className="h-12"
onClick={() =>
runAction("reprint", async () => {
// Opens in a new tab: an <iframe> would need a
// browser PDF viewer to print from, and Chrome backs
// those with an extension that ignores print().
const w = window.open(
`/api/invoicing/delivery-note-pdf?deliveryNoteNumber=${encodeURIComponent(
note.delivery_note_number,
)}`,
"_blank",
);
if (!w) flash("err", "Pop-up blocked — allow pop-ups to reprint.");
})
}
disabled={busy !== null}
>
{busy === "reprint" ? <Loader2 className="animate-spin" /> : <FileText />}
Reprint delivery note
</Button>
<Button
variant="outline"
className="h-12"
onClick={() => fileRef.current?.click()}
disabled={busy !== null || isDead}
>
{busy === "signed" ? <Loader2 className="animate-spin" /> : <Camera />}
Photograph / upload signed copy
</Button>
<input
ref={fileRef}
type="file"
accept="image/*,application/pdf"
className="hidden"
data-testid="scan-signed-input"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
void runAction("signed", async () => {
// NOTE: deliberately the upload-only endpoint, NOT
// /api/v1/deliveries/mark-signed-copy. That one flips
// the note to `completed` in the same write, which
// would jump a note straight past invoicing.
const fd = new FormData();
fd.append("file", file);
await api(`/api/v1/delivery-notes/${note.id}/signed-copy`, {
method: "POST",
body: fd,
});
flash("ok", "Signed copy uploaded");
await lookup(note.delivery_note_number);
});
}}
/>
{canLoad ? (
<Button
variant="secondary"
className="h-12"
onClick={() =>
runAction("load", async () => {
await api("/api/v1/deliveries/load-dn", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deliveryNoteId: note.id }),
});
flash("ok", "Marked as loaded");
await lookup(note.delivery_note_number);
})
}
disabled={busy !== null}
>
{busy === "load" ? <Loader2 className="animate-spin" /> : <Upload />}
Mark as loaded (for the driver)
</Button>
) : null}
{canPo ? (
<Button
variant="secondary"
className="h-12"
onClick={() => setShowPoBox((v) => !v)}
disabled={busy !== null}
>
<QrCode className="h-4 w-4" />
Capture customer PO number
</Button>
) : null}
{showPoBox ? (
<div className="flex flex-col gap-2 rounded-lg border bg-muted/30 p-3">
<Label htmlFor="scan-po" className="text-sm">
Customer PO number
</Label>
<Input
id="scan-po"
value={poDraft}
onChange={(e) => setPoDraft(e.target.value)}
placeholder="e.g. PO-88213"
/>
<div className="flex gap-2">
<Button
className="flex-1"
disabled={!poDraft.trim() || busy !== null}
onClick={() =>
runAction("po", async () => {
await api(`/api/v1/delivery-notes/${note.id}/customer-po`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customer_po: poDraft.trim() }),
});
flash("ok", "Customer PO saved");
setShowPoBox(false);
await lookup(note.delivery_note_number);
})
}
>
{busy === "po" ? <Loader2 className="animate-spin" /> : <CheckCircle2 />}
Save PO
</Button>
<Button variant="ghost" onClick={() => setShowPoBox(false)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : null}
{canInvoice ? (
<Button
className="h-12"
onClick={() =>
runAction("invoice", async () => {
await api(`/api/v1/delivery-notes/${note.id}/mark-invoiced`, {
method: "POST",
});
flash("ok", "Moved to ready to invoice");
await lookup(note.delivery_note_number);
})
}
disabled={busy !== null}
>
{busy === "invoice" ? <Loader2 className="animate-spin" /> : <CheckCircle2 />}
Move to ready to invoice
</Button>
) : null}
</div>
</section>
) : (
<section className="flex flex-col gap-4">
{support && !support.secureContext ? (
<p className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
This page is not on HTTPS, so the browser blocks the camera. Use the office
network&apos;s secure address, or type the number below.
</p>
) : null}
<div className="flex gap-2">
{cameraOn ? (
<Button variant="outline" className="h-12 flex-1" onClick={stopCamera}>
<CameraOff className="mr-1 h-4 w-4" />
Stop camera
</Button>
) : (
<Button
className="h-12 flex-1"
onClick={startCamera}
disabled={!support?.camera}
>
<Camera className="mr-1 h-4 w-4" />
Start camera
</Button>
)}
</div>
<div className="rounded-lg border p-3">
<Label htmlFor="scan-manual" className="text-sm">
Or type the delivery note number
</Label>
<div className="mt-1 flex gap-2">
<Input
id="scan-manual"
value={manual}
onChange={(e) => setManual(e.target.value.toUpperCase())}
placeholder="DN26100401"
className="font-mono"
data-testid="scan-manual-input"
/>
<Button
disabled={!parseDeliveryNoteNumber(manual) || busy !== null}
onClick={() => {
const dn = parseDeliveryNoteNumber(manual);
if (dn) void lookup(dn);
}}
>
{busy === "lookup" ? <Loader2 className="animate-spin" /> : null}
Go
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Works on any device, camera or not.
</p>
</div>
</section>
)}
{toast ? (
<div
data-testid="scan-toast"
className={`fixed inset-x-3 bottom-3 z-50 mx-auto max-w-md rounded-lg px-4 py-3 text-sm shadow-lg ${
toast.kind === "ok" ? "bg-emerald-600 text-white" : "bg-red-600 text-white"
}`}
>
{toast.text}
</div>
) : null}
</main>
);
}