From 970104e5108589ff53568a511b58b437cc379911 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 5 Oct 2026 22:56:22 +0200 Subject: [PATCH] feat(delivery-notes): invoice in QuickBooks from the list, with the order number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Create invoice" button used to navigate to the note's own page with ?invoice=1, which opened the invoice modal there. One click, but it threw her out of the list she was working through. It now opens in place, on the list. The ~180-line QuickBooks modal moved out of the detail page into `QbInvoiceDialog` and both screens mount it — the same pattern used for the action buttons and the readiness rule, both of which had drifted precisely because they existed twice. ## The order number Duplicated from the invoicing screen, which has had it all along: "Invoice order number", seeded from the note's order, editable, with a hard stop if it's empty when the operator tries to send. The backend already accepted `orderNumberOverride` — the route resolves `override || order.order_number || fallback` — so only the UI was missing, which is exactly what the operator asked for. That last fallback matters more here than on the invoicing screen: a counter delivery note usually has NO parent order, so this field is often the only place the number on the QuickBooks invoice can come from. A blank one would silently become `QB--`, which is not a number anyone recognises on an invoice. ## The auto-search could only ever fail The modal auto-loaded customers when it opened. The n8n webhook rejects an empty `customer_query` with a 400, the proxy turns that into a 502, so opening the dialog produced a red toast before the operator had touched anything. Verified against the live webhook: {"customer_query":""} -> 400 "customer_query is required" {"customer_query":"Caliber"} -> 200, real customers back There is now no auto-search: the operator types (the box is seeded for a note with an order) and presses Enter or Search. The loading flag also had to be rewritten. It was first derived from `loadedFor !== requestKey`, which meant typing in the search box flipped the spinner on while nothing re-ran the fetch, and it spun forever until Enter. `loading` now means "a request is genuinely in flight" and nothing else. Verified in the browser as David Els: Create invoice opens on the list with the order number pre-filled (Q202609244), a search for "Caliber" returns Caliber Machine and Fabrication (Pty) Ltd., and the URL never leaves /delivery-notes. Full suite 3676 passing, tsc clean. --- src/app/(app)/delivery-notes/[id]/page.tsx | 356 ++-------------- src/app/(app)/delivery-notes/page.tsx | 55 ++- .../delivery-notes/qb-invoice-dialog.tsx | 395 ++++++++++++++++++ 3 files changed, 474 insertions(+), 332 deletions(-) create mode 100644 src/components/delivery-notes/qb-invoice-dialog.tsx diff --git a/src/app/(app)/delivery-notes/[id]/page.tsx b/src/app/(app)/delivery-notes/[id]/page.tsx index 8035b8f..68dccf7 100644 --- a/src/app/(app)/delivery-notes/[id]/page.tsx +++ b/src/app/(app)/delivery-notes/[id]/page.tsx @@ -3,16 +3,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { DeliveryNoteActions } from "@/components/delivery-notes/action-buttons"; +import { QbInvoiceDialog } from "@/components/delivery-notes/qb-invoice-dialog"; import { QuoteReviewDialog } from "@/components/delivery-notes/quote-review-dialog"; import { use } from "react"; import { toast } from "sonner"; -import { - fetchCustomers, - type QbCustomer, -} from "@/app/api/invoicing/customers"; import { ArrowLeft, - Check, FileSignature, FileText, Loader2, @@ -306,16 +302,6 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) { // QuickBooks invoice-modal state. Lives on the page (not inside the // modal component) so we can re-mount it cleanly on every open and // so the customer-cache key is stable across re-opens. Mirrors the - // shape of the Invoicing page's QB modal, simplified for the - // delivery-note flow (no per-row editor — DN lines are fixed). - type QbCompany = "CNL" | "ISHERWOOD"; - const [qbCompany, setQbCompany] = useState("CNL"); - const [customerQuery, setCustomerQuery] = useState(""); - const [customer, setCustomer] = useState(null); - const [customers, setCustomers] = useState([]); - const [customersLoading, setCustomersLoading] = useState(false); - const [customersError, setCustomersError] = useState(null); - const loadAll = useCallback(async () => { setLoading(true); setError(""); @@ -418,21 +404,12 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) { // Step 1 (this commit) renders the button + placeholder modal so // the gating, disabled states, and overall UI shape are reviewable // before the full flow goes in. - // Open the invoice modal. Seed `customerQuery` from the DN's - // company name so the operator gets a meaningful default match - // (the Invoicing page does the same with its order's companyName). + // Open the invoice modal. The customer query is seeded from the DN's + // company name so the operator gets a meaningful default match (the + // Invoicing page does the same with its order's companyName). The + // shared dialog owns that state now. const openInvoiceModal = useCallback(() => { if (!deliveryNote) return; - const seed = ( - deliveryNote.expand?.company?.company ?? - deliveryNote.expand?.order?.company ?? - "" - ).trim(); - setCustomerQuery(seed); - setCustomer(null); - setCustomers([]); - setCustomersError(null); - setQbCompany("CNL"); setInvoiceModal({ open: true, saving: false }); }, [deliveryNote]); @@ -446,106 +423,11 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) { // 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 - // changes on every keystroke and would defeat memoization anyway. - // Only called from event handlers + a deliberately-deps'd effect. - const loadCustomers = async () => { - if (!invoiceModal.open) return; - setCustomersLoading(true); - setCustomersError(null); - try { - const list = await fetchCustomers({ - customerQuery, - billingCompany: qbCompany, - }); - setCustomers(list); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Failed to load QuickBooks customers"; - setCustomersError(message); - setCustomers([]); - } finally { - setCustomersLoading(false); - } - }; + // The QuickBooks customer lookup and the send itself now live in + // `QbInvoiceDialog`. The route behaviour is unchanged — the dialog + // POSTs the same body, including `orderNumberOverride`, which this + // screen previously never sent. - // Auto-load the QuickBooks customer list on modal open. We only - // refetch on (open, qbCompany) changes — typing in the search - // input is the operator's explicit "Search" click, not this effect. - // Mirrors the Invoicing page's discipline. - useEffect(() => { - if (invoiceModal.open) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: load on open transition; loadCustomers' internal setStates are not the "cascading-render" pattern the rule targets - void loadCustomers(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: load on open transition + toggle changes - }, [invoiceModal.open, qbCompany]); - - // Send the invoice + auto-close the DN. On 2xx, the route - // returns the updated `DeliveryNote` (status="completed", - // banner stamped on notes). On non-2xx, the modal stays open - // with an error toast — operator can retry without losing the - // customer they picked. - // Send the invoice + auto-close the DN. Plain function (not - // useCallback) — `customer`, `qbCompany`, and `customerQuery` - // change frequently and a stable reference would just churn - // downstream effects. The function is only invoked from the - // modal's "Send" button click, so referential instability is - // harmless. - const submitInvoiceModal = async () => { - if (!deliveryNote || !customer) return; - const pb = getPocketBase(); - const sender = (pb.authStore as unknown as { - model?: { id?: string; name?: string; email?: string } | null; - }).model; - setInvoiceModal((prev) => ({ ...prev, saving: true })); - setBusyAction("invoice_via_qb"); - try { - const res = await fetch( - `/api/v1/delivery-notes/${deliveryNote.id}/invoice`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - qbCompany, - customerId: customer.id, - customerName: customer.name, - sentById: sender?.id ?? "", - sentByName: sender?.name?.trim() || sender?.email?.trim() || "", - }), - }, - ); - if (!res.ok) { - let message = `Failed (${res.status})`; - try { - const data: unknown = await res.json(); - if ( - data && - typeof data === "object" && - "error" in data && - typeof (data as { error?: unknown }).error === "string" - ) { - message = (data as { error: string }).error; - } - } catch { - // ignore non-json - } - toast.error(`QuickBooks request failed: ${message}`); - return; - } - toast.success( - `Invoice sent to QuickBooks for ${deliveryNote.delivery_note_number}. Delivery note closed.`, - ); - setInvoiceModal({ open: false, saving: false }); - await loadAll(); - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : "Failed to invoice delivery note"); - } finally { - setBusyAction(null); - setInvoiceModal((prev) => ({ ...prev, saving: false })); - } - }; // ── Action: Mark Invoiced ─────────────────────────────────────── const handleMarkInvoiced = useCallback(async () => { @@ -2060,208 +1942,28 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) { - {/* - * Invoice via QuickBooks modal. Single-shot flow: - * 1. Operator picks the billing company (CNL / ISHERWOOD). - * 2. Customer list loads from `/api/invoicing/customers` - * (proxies the n8n webhook). Operator picks the - * matching QB customer from the result list. - * 3. Click "Send invoice + close" → POST to - * `/api/v1/delivery-notes/{id}/invoice`. The route - * fires the QB webhook and, on 2xx, calls - * `markDeliveryNoteCompletedByInvoice` to flip the - * DN `invoiced → completed` and stamp a banner on - * `notes`. On non-2xx the modal stays open and the - * operator can retry without losing their pick. - * - * Auto-fires the initial customer load on open so the - * list is populated by the time the operator reaches the - * dropdown. Customer query is seeded from the DN's - * company name (see `openInvoiceModal`). - */} - { - if (invoiceModal.saving) return; - setInvoiceModal({ open, saving: false }); + onOpenChange={(v) => setInvoiceModal({ open: v, saving: false })} + deliveryNoteId={deliveryNote.id} + orderNumber={deliveryNote.expand?.order?.order_number ?? ""} + customerQuerySeed={ + deliveryNote.expand?.company?.company ?? + deliveryNote.expand?.customer?.customer ?? + "" + } + onInvoiced={() => { + setInvoiceModal({ open: false, saving: false }); + void loadAll(); }} - > - - - - Invoice {deliveryNote.delivery_note_number} via QuickBooks - - - Order {deliveryNote.expand?.order?.order_number} ({deliveryNote.expand?.company?.company ?? "no company"}) will be sent to QuickBooks. On success this delivery note is automatically marked as invoiced and then completed. - - -
-
- -
- {(["CNL", "ISHERWOOD"] as const).map((opt) => { - const selected = qbCompany === opt; - const active = - "border-info bg-info/10 text-info ring-2 ring-info/30 dark:bg-info/15 dark:text-info dark:ring-info/40"; - const idle = - "border-info/30 bg-card text-info hover:bg-info/10 dark:border-info/40 dark:text-info"; - const altActive = - "border-warn bg-warn/10 text-warn-foreground ring-2 ring-warn/30"; - const altIdle = - "border-warn/30 bg-card text-warn hover:bg-warn/10"; - const cls = opt === "CNL" - ? (selected ? active : idle) - : (selected ? altActive : altIdle); - return ( - - ); - })} -
-
- -
- -
- setCustomerQuery(e.target.value)} - placeholder="QuickBooks customer name…" - disabled={invoiceModal.saving} - /> - -
-

- Edit the spelling if needed, then hit Search to re-query QuickBooks. -

-
- -
- -
- {customersError ? ( -
-

{customersError}

- -
- ) : customersLoading ? ( -

- Loading customers… -

- ) : customers.length === 0 ? ( -

- No matches. Hit Search to query QuickBooks, or refine the spelling. -

- ) : ( - customers.map((c) => { - const selected = c.id === customer?.id; - return ( - - ); - }) - )} -
-
-
- - - - -
-
+ /> {/* Inline PDF preview. The "Preview PDF" button in the action strip toggles this. Operator stays on the diff --git a/src/app/(app)/delivery-notes/page.tsx b/src/app/(app)/delivery-notes/page.tsx index d2724f2..28e5e4a 100644 --- a/src/app/(app)/delivery-notes/page.tsx +++ b/src/app/(app)/delivery-notes/page.tsx @@ -20,6 +20,7 @@ import { import { CreateAdHocDeliveryNoteDialog } from "@/components/delivery-notes/create-adhoc-dialog"; import { DeliveryNoteItemsExpanded } from "@/components/delivery-notes/items-expanded"; import { DeliveryNoteActions } from "@/components/delivery-notes/action-buttons"; +import { QbInvoiceDialog } from "@/components/delivery-notes/qb-invoice-dialog"; import { SendQuoteModal } from "@/components/quotes/send-quote-modal"; import { QuoteReviewDialog } from "@/components/delivery-notes/quote-review-dialog"; import { @@ -806,6 +807,34 @@ export default function DeliveryNotesPage() { }, [fetchNotes, markBusy, markIdle, poModal]); + // QuickBooks invoice, in place. Same component the detail page + // uses, so the order-number field and the customer picker behave + // identically on both screens. + const [invoiceModal, setInvoiceModal] = useState<{ + open: boolean; + noteId: string; + number: string; + orderNumber: string; + customerQuery: string; + }>({ + open: false, + noteId: "", + number: "", + orderNumber: "", + customerQuery: "", + }); + + const openInvoiceModal = useCallback((note: DeliveryNote) => { + setInvoiceModal({ + open: true, + noteId: note.id, + number: note.delivery_note_number, + orderNumber: note.expand?.order?.order_number ?? "", + customerQuery: + note.expand?.company?.company ?? note.expand?.customer?.customer ?? "", + }); + }, []); + // ── Status-driven action button ───────────────────────────────── // One button per row, swapped by the delivery note's status. The // button label + icon matches what the operator does next in the @@ -875,10 +904,8 @@ export default function DeliveryNotesPage() { send: () => quote && openSendModal(note, quote), // Slot 3 — the customer-to-customer path, always // available; no quote required. Opens the QuickBooks - // modal on the note's own page via ?invoice=1, which is - // one click and lands ready to use. - "invoice-qb": () => - router.push(`/delivery-notes/${note.id}?invoice=1`), + // modal right here, so she never leaves the list. + "invoice-qb": () => openInvoiceModal(note), }} /> @@ -888,7 +915,7 @@ export default function DeliveryNotesPage() { // here: the flow control offers quote / send / invoice only. Those // two actions live on the detail page, which still renders the // full set from the same component. - [busyIds, openQuoteReview, openSendModal, quoteByDnId, router], + [busyIds, openInvoiceModal, openQuoteReview, openSendModal, quoteByDnId, router], ); const columns = useMemo( @@ -1685,6 +1712,24 @@ export default function DeliveryNotesPage() { on any row that doesn't have a quote yet. The dialog POSTs the create itself with the operator's confirmed pricing; `handleQuoteCreated` only refreshes the row behind it. */} + {/* QuickBooks invoice, in place on the list. */} + {invoiceModal.noteId && ( + + setInvoiceModal((prev) => ({ ...prev, open: v })) + } + deliveryNoteId={invoiceModal.noteId} + orderNumber={invoiceModal.orderNumber} + customerQuerySeed={invoiceModal.customerQuery} + onInvoiced={() => { + setInvoiceModal((prev) => ({ ...prev, open: false })); + void fetchNotes(); + }} + /> + )} + {/* Send DN + quote, in place. This is the existing purpose-built modal (loud To field, alt-email toggle, rep auto-CC, PDF attached, retry banner on a bounce) mounted diff --git a/src/components/delivery-notes/qb-invoice-dialog.tsx b/src/components/delivery-notes/qb-invoice-dialog.tsx new file mode 100644 index 0000000..f861b11 --- /dev/null +++ b/src/components/delivery-notes/qb-invoice-dialog.tsx @@ -0,0 +1,395 @@ +"use client"; + +/** + * "Create invoice in QuickBooks" for a delivery note. + * + * ## Why this is its own component + * + * It started as ~180 lines embedded in the delivery-note DETAIL page. + * The operator asked for it on the LIST page too — clicking "Create + * invoice" on a row must not navigate away from the list she is + * working through. Rather than copy it (which is how the action + * buttons and the readiness rule both ended up drifting), the modal + * owns its own state here and both screens mount it. + * + * ## The order-number field + * + * Duplicated from the invoicing screen's QuickBooks modal, which has + * had it all along: "Invoice order number", seeded from the note's + * order, editable, with a hard stop if it's empty when they try to + * send. The route already accepted `orderNumberOverride` + * (`/api/v1/delivery-notes/[id]/invoice` resolves + * `override || order.order_number || fallback`) — only the UI was + * missing, so the backend needed no change. + * + * A counter note usually has no parent order at all, which is + * precisely when the operator needs to type one. + */ +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { AlertTriangle, Check, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + fetchCustomers, + type QbCustomer, +} from "@/app/api/invoicing/customers"; +import { cn } from "@/lib/utils"; +import getPocketBase from "@/lib/pocketbase"; + +/** Local alias — the same two books live as a local type in both + * `invoicing/page.tsx` and the delivery-note detail page. */ +type QbCompany = "CNL" | "ISHERWOOD"; + +export interface QbInvoiceDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + deliveryNoteId: string; + /** Seeds the order-number field. Empty for a counter note. */ + orderNumber?: string; + /** Seeds the customer search, so the list is useful on open. */ + customerQuerySeed?: string; + /** Fired after the invoice is created, with the updated note. */ + onInvoiced?: (deliveryNote: Record) => void; +} + +export function QbInvoiceDialog({ + open, + onOpenChange, + deliveryNoteId, + orderNumber = "", + customerQuerySeed = "", + onInvoiced, +}: QbInvoiceDialogProps) { + const [qbCompany, setQbCompany] = useState("CNL"); + const [customerQuery, setCustomerQuery] = useState(""); + const [customer, setCustomer] = useState(null); + const [customers, setCustomers] = useState([]); + // `loading` means "a request is genuinely in flight" — nothing else. + // + // It was first derived from `loadedFor !== requestKey`, which was + // wrong: typing in the search box changes `requestKey` and flips the + // spinner on, but the fetch effect deliberately doesn't re-run on + // every keystroke (typing is not a request), so it spun forever + // until the operator pressed Enter. Loading has to describe the + // request, not the query string. + const [pendingKey, setPendingKey] = useState(null); + const [searchNote, setSearchNote] = useState(null); + const customersLoading = pendingKey !== null; + const [orderNumberOverride, setOrderNumberOverride] = useState(""); + const [saving, setSaving] = useState(false); + + // Seed once per open cycle. Remounting on the note id (via `key` at + // the call site) is what guarantees a fresh modal for a different + // note; this only handles the open/close transition. + useEffect(() => { + if (!open) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional open/close seed + setOrderNumberOverride(orderNumber); + setCustomerQuery(customerQuerySeed); + setCustomer(null); + }, [open, orderNumber, customerQuerySeed]); + + /** + * Search QuickBooks customers — on demand only. + * + * It used to auto-fire when the modal opened, which could only ever + * fail: the n8n webhook rejects an empty `customer_query` with a + * 400, the proxy turns that into a 502, and the operator opens the + * dialog to a red toast before touching anything. Verified against + * the live webhook: + * {"customer_query":""} -> 400 "customer_query is required" + * {"customer_query":"Caliber"} -> 200, real customers back + * + * So no auto-search. The operator types (the box is seeded for a + * note that has an order) and presses Enter or Search. + */ + const runSearch = useCallback(async () => { + const q = customerQuery.trim(); + if (!q) { + // Upstream requires a non-empty query. Saying so beats firing a + // request that is guaranteed to fail. + setCustomers([]); + setSearchNote("Type a customer name, then press Enter."); + return; + } + setSearchNote(null); + setPendingKey(`${qbCompany}::${q}`); + try { + setCustomers( + await fetchCustomers({ + customerQuery: q, + billingCompany: qbCompany, + }), + ); + } catch (err: unknown) { + setCustomers([]); + toast.error( + err instanceof Error + ? err.message + : "Could not reach QuickBooks — check the n8n webhook", + ); + } finally { + setPendingKey(null); + } + }, [customerQuery, qbCompany]); + + const submit = useCallback(async () => { + if (!customer) return; + // Last look at the order number. A typo here ships a wrong number + // to QuickBooks and nothing downstream will flag it, so we stop + // before firing rather than after. + const trimmedOrderNumber = orderNumberOverride.trim(); + if (!trimmedOrderNumber) { + toast.error( + "Order number is empty. Enter an order number before sending to QuickBooks.", + ); + return; + } + + const sender = ( + getPocketBase().authStore as unknown as { + model?: { id?: string; name?: string; email?: string } | null; + } + ).model; + + setSaving(true); + try { + const res = await fetch( + `/api/v1/delivery-notes/${deliveryNoteId}/invoice`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + qbCompany, + customerId: customer.id, + customerName: customer.name, + orderNumberOverride: trimmedOrderNumber, + sentById: sender?.id ?? "", + sentByName: + sender?.name?.trim() || sender?.email?.trim() || "", + }), + }, + ); + if (!res.ok) { + let message = `Failed to invoice (${res.status})`; + try { + const body: unknown = await res.json(); + if ( + body && + typeof body === "object" && + "error" in body && + typeof (body as { error?: unknown }).error === "string" + ) { + message = (body as { error: string }).error; + } + } catch { + // ignore non-json + } + // The modal stays open so the pick isn't lost. + throw new Error(message); + } + const updated = (await res.json()) as Record; + toast.success("Invoice created in QuickBooks."); + onInvoiced?.(updated); + onOpenChange(false); + } catch (err: unknown) { + toast.error( + err instanceof Error + ? err.message + : "Failed to invoice delivery note", + ); + } finally { + setSaving(false); + } + }, [ + customer, + deliveryNoteId, + onInvoiced, + onOpenChange, + orderNumberOverride, + qbCompany, + ]); + + return ( + { + if (saving) return; + onOpenChange(next); + }}> + + + Create invoice in QuickBooks + + Pick the billing company, check the order number, then pick + the QuickBooks customer to invoice. + + + +
+ {/* Billing company */} +
+ +
+ {(["CNL", "ISHERWOOD"] as const).map((opt) => { + const selected = qbCompany === opt; + return ( + + ); + })} +
+
+ + {/* Order number — the piece duplicated from the invoicing + screen. A counter note usually has no parent order, so + this is how the number that lands on the invoice gets + set at all. */} +
+ + setOrderNumberOverride(e.target.value)} + placeholder="e.g. PO-9931 or the customer's order number" + disabled={saving} + data-testid="dn-invoice-order-number" + /> +

+ This is the order number that appears on the QuickBooks + invoice.{" "} + {!orderNumber && ( + <> + This delivery note has no order of its own, so type + the number the customer gave you. + + )} +

+
+ + {/* QuickBooks customer */} +
+ + setCustomerQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void runSearch(); + } + }} + placeholder="Type a customer name, then press Enter" + disabled={saving} + data-testid="dn-invoice-qb-query" + /> +
+ +
+
+ {customersLoading ? ( +

+ Searching QuickBooks… +

+ ) : customers.length === 0 ? ( +

+ {searchNote ?? "No QuickBooks customers match."} +

+ ) : ( + customers.map((c) => { + const selected = customer?.id === c.id; + return ( + + ); + }) + )} +
+ {!orderNumber.trim() && ( +

+ + No order number yet — you won't be able to send + until you type one above. +

+ )} +
+
+ + + + + +
+
+ ); +} \ No newline at end of file