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() {