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.
637 lines
23 KiB
TypeScript
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'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>
|
|
);
|
|
}
|