diff --git a/src/app/(app)/delivery-notes/[id]/page.tsx b/src/app/(app)/delivery-notes/[id]/page.tsx index 23159be..3619750 100644 --- a/src/app/(app)/delivery-notes/[id]/page.tsx +++ b/src/app/(app)/delivery-notes/[id]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { use } from "react"; import { toast } from "sonner"; import { @@ -194,6 +194,15 @@ async function fetchDeliveryNoteDetail(id: string): Promise } export default function DeliveryNoteDetailPage({ params }: PageProps) { + /** + * `?invoice=1` opens the QuickBooks invoice modal straight away. + * The delivery-notes list links here rather than duplicating that + * modal, so "invoice directly" and "create quote" are each one click + * from the same row. + */ + const searchParams = useSearchParams(); + const wantInvoice = searchParams.get("invoice") === "1"; + const router = useRouter(); // `params` is a Promise in Next.js 15+ — unwrap with React.use. const { id } = use(params); @@ -461,6 +470,16 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) { setInvoiceModal({ open: true, saving: false }); }, [deliveryNote]); + useEffect(() => { + if (!wantInvoice) return; + // Wait for the note to arrive — `openInvoiceModal` no-ops on a + // null deliveryNote, which would leave a dead page. + if (!deliveryNote) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: a deep-link opening a modal once the note has loaded, not a derived-state sync + openInvoiceModal(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- fire once, when the note arrives + }, [wantInvoice, deliveryNote]); + // Fetch QuickBooks customers for the currently-picked billing // company + the (possibly edited) `customerQuery`. Plain function // (not useCallback) because the body reads `customerQuery`, which @@ -1385,20 +1404,23 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) { * buttons stay as outline alternatives for operators * who don't want to invoice right now. */} - {status !== "completed" && - status !== "cancelled" && - deliveryNote.expand?.order && ( + {/* Not gated on a parent order any more. Ad-hoc notes — the + ones the POS raises for a counter account sale — are + exactly the ones the counter wants to invoice DIRECTLY + instead of routing through a quote, and the invoice route + now supplies a reference for them. */} + {status !== "completed" && status !== "cancelled" && ( - )} + )} {status === "invoiced" && ( )} - {/* $ — placeholder for the QuickBooks invoice flow - (v3-D). Always muted, never clickable, but the - row reads the same regardless of status. */} + {/* $ — invoice this note directly through QuickBooks. + This is the counter's "some customers we invoice, others + we quote" choice, so it sits next to Q (create quote) on + the same row. It opens the detail page's invoice modal + via ?invoice=1 rather than duplicating that modal here — + it needs a QuickBooks customer search, a billing-company + toggle and an order-number field, and two copies of a + financial form is two copies to keep in step. + + Enabled on any non-terminal note. The route used to + refuse ad-hoc notes outright, which meant a POS-raised + counter sale could only ever be quoted, never invoiced. */} {/* Mail — send the DN + quote to the customer. Only enabled when a quote exists; without a quote @@ -951,6 +972,31 @@ export default function DeliveryNotesPage() { ); }, }, + { + key: "signed", + label: "Signed", + render: (item: DeliveryNote) => { + const names = fileNames(item.signed_copy); + if (names.length === 0) { + return ( + — + ); + } + return ( + + + On file + + ); + }, + }, { key: "created", label: "Created", diff --git a/src/app/api/v1/delivery-notes/[id]/invoice/route.ts b/src/app/api/v1/delivery-notes/[id]/invoice/route.ts index 64b0153..369cae5 100644 --- a/src/app/api/v1/delivery-notes/[id]/invoice/route.ts +++ b/src/app/api/v1/delivery-notes/[id]/invoice/route.ts @@ -130,18 +130,24 @@ export async function POST( { status: 409 }, ); } - // Ad-hoc delivery notes (no parent order) still can't be invoiced - // — the QB webhook requires an order_number to attach the - // invoice to. This is the one structural gate that stays. - if (!dn.expand?.order?.id || !dn.expand.order.order_number) { - return NextResponse.json( - { - error: - "Ad-hoc delivery notes (no parent order) cannot be invoiced through this endpoint.", - }, - { status: 422 }, - ); - } + // Ad-hoc delivery notes — the ones the POS raises for a counter + // account sale — have no parent order. This used to be a hard 422, + // on the assumption that the QB webhook needs an order_number to + // attach the invoice to. It does not need an ORDER number: what the + // payload requires is a non-empty reference, and + // `buildQbInvoicePayload` types `orderNumber` as optional (falling + // back to ""). So one is always supplied here — the operator's + // override if given, else the parent order's number, else the + // delivery note number, which is stable, unique and traceable back + // to exactly this delivery. + // + // This is the path the counter uses for "some customers we invoice + // directly, others we quote". Without it every POS-raised note has + // to be turned into a quote first. + const orderNumber = + (typeof body.orderNumberOverride === "string" ? body.orderNumberOverride : "").trim() || + (dn.expand?.order?.order_number ?? "").trim() || + `DN-${dn.delivery_note_number}`; // Read the order_items grouped under this delivery note by the // canonical `delivery_note_number` join. @@ -182,7 +188,7 @@ export async function POST( })); const payload = buildQbInvoicePayload({ - orderNumber: dn.expand.order.order_number, + orderNumber, billingCompany: body.qbCompany, invoiceCompany: body.customerName, customer: { id: body.customerId, name: body.customerName }, @@ -245,7 +251,7 @@ export async function POST( qbInvoice?.number ?? webhookBody.qb_invoice_id ?? webhookBody.id ?? - `QB-${dn.expand.order.order_number}-${new Date().toISOString()}`; + `QB-${orderNumber}-${new Date().toISOString()}`; // Reconcile PB's per-row invoiced-state against what we actually // just billed. n8n's "Build PocketBase Invoice Update" node diff --git a/src/app/scan/page.tsx b/src/app/scan/page.tsx index 50efc5d..c940922 100644 --- a/src/app/scan/page.tsx +++ b/src/app/scan/page.tsx @@ -35,23 +35,12 @@ 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 { Camera, CameraOff, FileText, Loader2, QrCode, RefreshCw } from "lucide-react"; import getPocketBase from "@/lib/pocketbase"; import { detectScannerSupport, parseDeliveryNoteNumber, scanVideoFrames, - type QrDetectorKind, } from "@/lib/scan/qr-detect"; type Resolved = { @@ -106,11 +95,19 @@ export default function ScanPage() { // browser drops the lock whenever the page is hidden). const wakeRef = React.useRef(null); - const [support, setSupport] = React.useState<{ - kind: QrDetectorKind; - camera: boolean; - secureContext: boolean; - } | null>(null); + /** + * Capability snapshot. `useSyncExternalStore` with a null server + * snapshot rather than `useState` + `useEffect`: the value only + * exists in a browser (it reads `navigator` and `window`), so the + * first client render has to MATCH the server's null and then + * re-render with the real answer. Setting state inside an effect to + * get there is the React 19 `set-state-in-effect` anti-pattern. + */ + const support = React.useSyncExternalStore( + () => () => {}, + () => detectScannerSupport(), + () => null, + ); const [cameraOn, setCameraOn] = React.useState(false); const [manual, setManual] = React.useState(""); const [note, setNote] = React.useState(null); @@ -119,12 +116,6 @@ export default function ScanPage() { // 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. @@ -226,8 +217,6 @@ export default function ScanPage() { `/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 @@ -298,22 +287,15 @@ export default function ScanPage() { ); 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"; + // A cancelled or completed note is closed; attaching a signed copy + // to it afterwards would misrepresent the paperwork. 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"; + + + // The scanner is capture-only, so it does not decide the next step — + // it says where the note sits and points at the list where the + // quote-or-invoice decision is made. + const nextStep = `Status: ${STATUS_COPY[status] ?? status}. Decide quote vs invoice on the delivery notes list.`; return (
@@ -325,7 +307,7 @@ export default function ScanPage() {

{note ? ( - @@ -474,96 +456,17 @@ export default function ScanPage() { }} /> - {canLoad ? ( - - ) : null} + {/* + NOTE — the ladder actions (Mark as loaded, Capture PO, + Move to ready to invoice) used to live here. They don't + any more. Whether a note is quoted or invoiced directly + is a PER-CUSTOMER commercial decision, and the operator + who makes it is at the delivery-notes list in WFDASH, + not standing at the van with a phone. /scan captures: + read the note, reprint it, photograph the signed copy. + Full stop. + */} - {canPo ? ( - - ) : null} - - {showPoBox ? ( -
- - setPoDraft(e.target.value)} - placeholder="e.g. PO-88213" - /> -
- - -
-
- ) : null} - - {canInvoice ? ( - - ) : null} ) : (