feat(delivery-notes): invoice a scanned note directly, or quote it
Whether a counter sale is invoiced or quoted is a PER-CUSTOMER
commercial decision, and it belongs to whoever is at the delivery
notes list in WFDASH — not to someone standing at the van with a
phone. So the scanner is now capture-only, and the choice lives in
the list.
The scanner keeps exactly three things: read the note, reprint it,
photograph the signed copy. The ladder buttons (Mark as loaded,
Capture PO, Move to ready to invoice) are gone from it, along with
the state they needed.
In the list, the `$` action was a permanently-disabled placeholder
reading "Invoice — coming soon (v3-D)". It is now real, sitting next
to Q (create quote) on the same row so the operator picks one or the
other per customer.
Getting there needed one substantive change. The invoice route had a
deliberate guard:
"Ad-hoc delivery notes (no parent order) cannot be invoiced
through this endpoint."
which is why the button was a placeholder — a POS-raised counter
sale has `order: null`, so it could only ever be quoted, never
invoiced. The stated reason was that "the QB webhook requires an
order_number to attach the invoice to", but that requirement is
about a non-empty reference, not about it coming from an order:
`buildQbInvoicePayload` types `orderNumber` as optional and falls
back to "". The route now always supplies one — operator override,
else the parent order's number, else the delivery note number, which
is stable, unique and traceable. That same resolved value replaces
the `dn.expand.order.order_number` read in the synthetic invoice-
number fallback, which would otherwise have thrown on an ad-hoc note.
The detail page's "Invoice via QuickBooks" button was also gated on
`expand?.order`; it now shows for any non-terminal note and opens
via `?invoice=1`, so the list links to it rather than duplicating a
financial modal (QuickBooks customer search + billing-company toggle
+ order number) that would then have to be kept in step.
Also adds a Signed column to the list. No new status value: the
`draft` status is left alone as agreed, and "paperwork is back" is
shown from `signed_copy` itself, linking straight to the file. That
is what makes a scanned-and-signed note distinguishable from a
freshly-created one in the list.
No lint errors added — both set-state-in-effect errors in the two
delivery-note pages are pre-existing and verified unchanged against
HEAD.
This commit is contained in:
parent
4e4e7328b3
commit
77346e7716
4 changed files with 135 additions and 158 deletions
|
|
@ -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<DeliveryNoteDetail>
|
|||
}
|
||||
|
||||
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" && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busyAction !== null}
|
||||
onClick={openInvoiceModal}
|
||||
data-testid={`dn-detail-invoice-via-qb-${deliveryNote.id}`}
|
||||
title={`Send order ${deliveryNote.expand.order.order_number} to QuickBooks and mark this DN as invoiced + completed`}
|
||||
title={`Send this delivery note to QuickBooks and mark it invoiced + completed`}
|
||||
>
|
||||
<Banknote className="mr-1 h-3.5 w-3.5" />
|
||||
Invoice via QuickBooks
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
{status === "invoiced" && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
} from "@/lib/pb-helpers";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { isRepUser } from "@/lib/rep-visibility";
|
||||
import { fileNames } from "@/lib/file-fields";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Banknote,
|
||||
|
|
@ -720,6 +721,7 @@ export default function DeliveryNotesPage() {
|
|||
const ActionCell = useCallback(
|
||||
(note: DeliveryNote) => {
|
||||
const busy = busyIds.has(note.id);
|
||||
const isTerminal = note.status === "completed" || note.status === "cancelled";
|
||||
const quote = quoteByDnId.get(note.id);
|
||||
const hasQuote = !!quote;
|
||||
const spinner = (
|
||||
|
|
@ -796,19 +798,38 @@ export default function DeliveryNotesPage() {
|
|||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* $ — 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. */}
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
disabled={busy || isTerminal}
|
||||
onClick={() =>
|
||||
router.push(`/delivery-notes/${note.id}?invoice=1`)
|
||||
}
|
||||
data-testid={`dn-action-invoice-${note.id}`}
|
||||
aria-label="Invoice (coming soon — v3-D QuickBooks)"
|
||||
title="Invoice — coming soon (v3-D)"
|
||||
className={iconBtnMuted}
|
||||
aria-label="Invoice via QuickBooks"
|
||||
title="Invoice via QuickBooks"
|
||||
className={
|
||||
isTerminal
|
||||
? iconBtnMuted
|
||||
: "inline-flex items-center justify-center rounded-md border border-info/30 bg-info/10 hover:bg-info/15 transition-colors"
|
||||
}
|
||||
style={{ width: "1.75rem", height: "1.75rem" }}
|
||||
>
|
||||
<Banknote className="h-3.5 w-3.5" aria-hidden />
|
||||
<Banknote
|
||||
className={isTerminal ? "" : "text-info"}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{/* 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 (
|
||||
<span className="text-xs text-muted-foreground/70">—</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={`/api/v1/delivery-notes/${item.id}/signed-copy`}
|
||||
target="_blank"
|
||||
rel="noopener,noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-emerald-700 hover:underline"
|
||||
title={`View signed copy: ${names.join(", ")}`}
|
||||
data-testid={`dn-signed-${item.id}`}
|
||||
>
|
||||
<FileSignature className="h-3.5 w-3.5" aria-hidden />
|
||||
On file
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "created",
|
||||
label: "Created",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<WakeLockSentinelLike | null>(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<Resolved | null>(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 (
|
||||
<main className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col gap-5 px-4 py-6">
|
||||
|
|
@ -325,7 +307,7 @@ export default function ScanPage() {
|
|||
</p>
|
||||
</div>
|
||||
{note ? (
|
||||
<Button variant="outline" size="sm" onClick={() => { setNote(null); setPoDraft(""); }}>
|
||||
<Button variant="outline" size="sm" onClick={() => setNote(null)}>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
Scan another
|
||||
</Button>
|
||||
|
|
@ -474,96 +456,17 @@ export default function ScanPage() {
|
|||
}}
|
||||
/>
|
||||
|
||||
{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}
|
||||
{/*
|
||||
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 ? (
|
||||
<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>
|
||||
) : (
|
||||
|
|
|
|||
Loading…
Reference in a new issue