From d98c263f457ef26c6f77603ab4ea909ca25d3bf5 Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 4 Oct 2026 13:15:56 +0200 Subject: [PATCH] feat(scan): standalone delivery-note scanner PWA at /scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan the QR printed on a delivery note, see what it is, and act on it: reprint, photograph the signed copy, or walk it toward invoicing. It is its own route group outside `(app)` so it gets a clean full-bleed layout and its own manifest — "add to home screen" should give the operator a scanner, not the CRM. Mirrors the pattern `(counter)` already uses. Two detectors, not one. `BarcodeDetector` is fast and allocation-free but is Chrome-only: Safari/iOS still has it "Under Consideration" (WebKit bug #254573) and Firefox does not ship it. A scanner that only used it would simply not work on an iPhone. So native where it exists, jsQR everywhere else — pure JS, so no 2 MB WASM payload on a weak warehouse signal, and the delivery-note QR is always a QR code. Both run against the same canvas frame so the caller never branches. Also requires HTTPS, which the page says plainly when it isn't. The QR carries the human number, so the first call is a new resolve endpoint mapping number -> PB id. Everything after that speaks ids, which is what every existing route already takes. The number is shape-validated before any network call, so a supplier's packing label can't fire a lookup. STATUS LADDER. 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 action only appears when the server would accept it, and the card shows which step comes next. It routes draft -> loaded -> po_received -> invoiced via the existing load-dn, NOT draft -> sent, because draft -> sent means "create a quote and email it" and a warehouse scan must never trigger that silently. The signed-copy upload deliberately uses 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. Manual entry is always available, so the page works on a device with no camera or a browser that won't scan. Tests decode the REAL delivery-note QR through the production fallback path at print size, and assert the shape check rejects a supplier QR, a random string and an over-long number. --- public/manifest-scan.json | 27 + .../api/v1/delivery-notes/resolve/route.ts | 101 ++++ src/app/scan/layout.tsx | 24 + src/app/scan/page.tsx | 549 ++++++++++++++++++ src/components/sidebar.tsx | 5 + src/lib/scan/qr-detect.test.ts | 173 ++++++ src/lib/scan/qr-detect.ts | 211 +++++++ 7 files changed, 1090 insertions(+) create mode 100644 public/manifest-scan.json create mode 100644 src/app/api/v1/delivery-notes/resolve/route.ts create mode 100644 src/app/scan/layout.tsx create mode 100644 src/app/scan/page.tsx create mode 100644 src/lib/scan/qr-detect.test.ts create mode 100644 src/lib/scan/qr-detect.ts diff --git a/public/manifest-scan.json b/public/manifest-scan.json new file mode 100644 index 0000000..f97b632 --- /dev/null +++ b/public/manifest-scan.json @@ -0,0 +1,27 @@ +{ + "name": "WFDASH Delivery Scanner", + "short_name": "DN Scanner", + "description": "Scan the QR on a delivery note to reprint it, capture the signed copy, or move it to ready to invoice", + "start_url": "/scan", + "scope": "/", + "display": "standalone", + "background_color": "#FFFFFF", + "theme_color": "#4C96D7", + "orientation": "portrait", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ], + "categories": ["business", "productivity", "utilities"], + "prefer_related_applications": false +} diff --git a/src/app/api/v1/delivery-notes/resolve/route.ts b/src/app/api/v1/delivery-notes/resolve/route.ts new file mode 100644 index 0000000..efbdd6c --- /dev/null +++ b/src/app/api/v1/delivery-notes/resolve/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertBrowserSession } from "@/lib/server/browser-session"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/v1/delivery-notes/resolve?number=DN26100401 + * + * Turns a scanned delivery note NUMBER into the record the rest of the + * API speaks in (a 15-char PB id). + * + * The QR on the printed note carries the human number, because that is + * what the operator reads out and what the paper already shows. Every + * existing route — detail, signed-copy, mark-invoiced, customer-po — + * is keyed on the PB id, so the scanner needs this hop. It is the only + * place a number becomes an id, which keeps that translation in one + * auditable spot instead of spreading a guess-the-key lookup over the + * client and every action button. + * + * Returns a compact summary rather than the full detail read model: + * the scanner card needs the header, the line count and the status (to + * decide which ladder step to offer), not the quote history. + * + * Errors: + * - 401 no browser session + * - 400 missing / malformed `number` + * - 404 no delivery note with that number + */ +export async function GET(req: NextRequest) { + try { + await assertBrowserSession(req); + } catch (error) { + const message = error instanceof Error ? error.message : "Unauthorized"; + return NextResponse.json({ error: message }, { status: 401 }); + } + + const raw = (req.nextUrl.searchParams.get("number") ?? "").trim().toUpperCase(); + if (!raw) { + return NextResponse.json({ error: "number is required" }, { status: 400 }); + } + // `DN` + 8-10 digits. Matches the validation in + // `src/lib/scan/qr-detect.ts`; the scanner checks first, this is the + // server-side belt so a hand-rolled request can't put a PB filter + // string in front of an unvalidated value. + if (!/^DN\d{8,10}$/.test(raw)) { + return NextResponse.json( + { error: `"${raw}" is not a delivery note number (expected DN followed by 8-10 digits).` }, + { status: 400 }, + ); + } + + try { + const pb = await getAdminPocketBase(); + // The number is regex-validated to DN + digits only, so it cannot + // contain a quote or a PB filter operator. + // Same collection name the service uses; it is a module-local + // const there and not exported, so we repeat the literal. + const page = await pb.collection("delivery_notes").getList>( + 1, + 1, + { + filter: `delivery_note_number = "${raw}"`, + expand: "customer,company", + }, + ); + const dn = page.items[0]; + if (!dn) { + return NextResponse.json( + { error: `No delivery note found for ${raw}.` }, + { status: 404 }, + ); + } + + const customer = (dn as { expand?: { customer?: { customer?: string } } }).expand?.customer; + const company = (dn as { expand?: { company?: { company?: string } } }).expand?.company; + const itemCount = await pb + .collection("order_items") + .getList(1, 1, { filter: `delivery_note_number = "${raw}"` }) + .then((p) => p.totalItems ?? p.items.length) + .catch(() => 0); + + return NextResponse.json({ + id: dn.id, + delivery_note_number: dn.delivery_note_number, + status: dn.status, + customer_name: customer?.customer ?? "", + company_name: company?.company ?? "", + customer_po: dn.customer_po ?? "", + delivery_date: dn.delivery_date ?? "", + signed_copy: Array.isArray(dn.signed_copy) ? dn.signed_copy[0] ?? "" : "", + item_count: itemCount, + created: dn.created, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to resolve delivery note"; + const status = (error as Error & { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/scan/layout.tsx b/src/app/scan/layout.tsx new file mode 100644 index 0000000..58818d0 --- /dev/null +++ b/src/app/scan/layout.tsx @@ -0,0 +1,24 @@ +import type { Metadata, Viewport } from "next"; + +/** + * /scan is its own route group (outside `(app)`) so it gets a + * dedicated PWA manifest — "add to home screen" should give the + * operator a scanner, not the full CRM. Mirrors the pattern already + * used by `(counter)` for `/manifest-counter.json`. + */ +export const metadata: Metadata = { + title: "DN Scanner · WFDASH", + manifest: "/manifest-scan.json", + applicationName: "WFDASH DN Scanner", +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", + themeColor: "#4C96D7", +}; + +export default function ScanLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/src/app/scan/page.tsx b/src/app/scan/page.tsx new file mode 100644 index 0000000..2433cea --- /dev/null +++ b/src/app/scan/page.tsx @@ -0,0 +1,549 @@ +"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; + +const STATUS_COPY: Record = { + 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(null); + const canvasRef = React.useRef(null); + const streamRef = React.useRef(null); + const abortRef = React.useRef(null); + const fileRef = React.useRef(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(null); + const [busy, setBusy] = React.useState(null); + const [toast, setToast] = React.useState(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()); + }, + [], + ); + + const flash = React.useCallback((kind: "ok" | "err", text: string) => { + setToast({ kind, text }); + window.setTimeout(() => setToast(null), 6000); + }, []); + + const stopCamera = React.useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + streamRef.current?.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + setCameraOn(false); + }, []); + + 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) => { + 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); + stopCamera(); + 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, stopCamera], + ); + + 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); + 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) => { + 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 ( +
+
+
+

Delivery note scanner

+

+ Scan the QR on a printed delivery note +

+
+ {note ? ( + + ) : null} +
+ + {/* ---- the note card, or the scanner ---- */} + {note ? ( +
+
+
+
+

{note.delivery_note_number}

+

+ {note.company_name || note.customer_name || "—"} +

+ {note.customer_name ? ( +

{note.customer_name}

+ ) : null} +
+ + {STATUS_COPY[status] ?? status} + +
+
+
+
Items
+
{note.item_count}
+
+
+
Customer PO
+
{note.customer_po || "—"}
+
+ {note.signed_copy ? ( +
+
+ Signed copy +
+
{note.signed_copy}
+
+ ) : null} +
+
+ +

{nextStep}

+ +
+