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}

+ +
+