feat(delivery-notes): invoice in QuickBooks from the list, with the order number

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-<dn>-<timestamp>`, 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.
This commit is contained in:
Dave 2026-10-05 22:56:22 +02:00
parent d983eccd6c
commit 970104e510
3 changed files with 474 additions and 332 deletions

View file

@ -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<QbCompany>("CNL");
const [customerQuery, setCustomerQuery] = useState("");
const [customer, setCustomer] = useState<QbCustomer | null>(null);
const [customers, setCustomers] = useState<QbCustomer[]>([]);
const [customersLoading, setCustomersLoading] = useState(false);
const [customersError, setCustomersError] = useState<string | null>(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) {
</DialogContent>
</Dialog>
{/*
* 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`).
*/}
<Dialog
{/* Invoice via QuickBooks. Now the SHARED component, so this
screen and the delivery-notes list behave identically —
including the "Invoice order number" field, which this copy
never had. The backend already accepted `orderNumberOverride`
(the route resolves `override || order.order_number ||
fallback`); only the UI was missing. */}
<QbInvoiceDialog
key={`dn-qb-invoice-${deliveryNote.id}`}
open={invoiceModal.open}
onOpenChange={(open) => {
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();
}}
>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>
Invoice {deliveryNote.delivery_note_number} via QuickBooks
</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Billing company
</Label>
<div
role="radiogroup"
aria-label="QuickBooks company"
className="mt-1.5 inline-flex gap-2"
>
{(["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 (
<button
key={opt}
type="button"
role="radio"
aria-checked={selected}
disabled={invoiceModal.saving}
onClick={() => {
setQbCompany(opt);
setCustomer(null);
}}
className={cn(
"rounded-md border-2 px-4 py-1.5 text-sm font-semibold uppercase tracking-wide transition-colors disabled:opacity-50",
cls,
)}
>
{opt}
</button>
);
})}
</div>
</div>
<div>
<Label htmlFor="dn-invoice-qb-query" className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Customer search
</Label>
<div className="mt-1.5 flex gap-2">
<Input
id="dn-invoice-qb-query"
value={customerQuery}
onChange={(e) => setCustomerQuery(e.target.value)}
placeholder="QuickBooks customer name…"
disabled={invoiceModal.saving}
/>
<Button
variant="outline"
size="sm"
onClick={() => void loadCustomers()}
disabled={invoiceModal.saving || customersLoading}
data-testid={`dn-invoice-search-${deliveryNote.id}`}
>
{customersLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
"Search"
)}
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Edit the spelling if needed, then hit Search to re-query QuickBooks.
</p>
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
QuickBooks customer
</Label>
<div
role="radiogroup"
aria-label="QuickBooks customer"
className="mt-1.5 max-h-64 space-y-1 overflow-y-auto rounded-md border bg-background p-1.5"
>
{customersError ? (
<div className="space-y-2 px-2 py-3 text-center">
<p className="text-xs text-destructive">{customersError}</p>
<Button
variant="outline"
size="sm"
onClick={() => void loadCustomers()}
disabled={invoiceModal.saving || customersLoading}
>
Retry
</Button>
</div>
) : customersLoading ? (
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
Loading customers…
</p>
) : customers.length === 0 ? (
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
No matches. Hit Search to query QuickBooks, or refine the spelling.
</p>
) : (
customers.map((c) => {
const selected = c.id === customer?.id;
return (
<button
key={c.id}
type="button"
role="radio"
aria-checked={selected}
disabled={invoiceModal.saving}
onClick={() => setCustomer(selected ? null : c)}
data-testid={`dn-invoice-customer-${c.id}`}
className={cn(
"flex w-full items-center gap-2 rounded px-3 py-2 text-left text-sm transition-colors",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
selected
? "bg-info text-info-foreground shadow-sm ring-2 ring-info/40"
: "border border-transparent hover:border-info/40 hover:bg-info/10",
)}
>
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full border",
selected
? "border-info-foreground bg-info-foreground text-info"
: "border-muted-foreground/40 bg-background text-transparent",
)}
>
<Check className="h-3.5 w-3.5" strokeWidth={3} />
</span>
<span className="flex-1 truncate font-medium">{c.name}</span>
</button>
);
})
)}
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setInvoiceModal({ open: false, saving: false })}
disabled={invoiceModal.saving}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => void submitInvoiceModal()}
disabled={invoiceModal.saving || !customer}
data-testid={`dn-invoice-send-${deliveryNote.id}`}
>
{invoiceModal.saving && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
)}
Send invoice + close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
/>
{/* Inline PDF preview. The "Preview PDF" button in the
action strip toggles this. Operator stays on the

View file

@ -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),
}}
/>
</div>
@ -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 && (
<QbInvoiceDialog
key={`dn-qb-invoice-${invoiceModal.noteId}`}
open={invoiceModal.open}
onOpenChange={(v) =>
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

View file

@ -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<string, unknown>) => void;
}
export function QbInvoiceDialog({
open,
onOpenChange,
deliveryNoteId,
orderNumber = "",
customerQuerySeed = "",
onInvoiced,
}: QbInvoiceDialogProps) {
const [qbCompany, setQbCompany] = useState<QbCompany>("CNL");
const [customerQuery, setCustomerQuery] = useState("");
const [customer, setCustomer] = useState<QbCustomer | null>(null);
const [customers, setCustomers] = useState<QbCustomer[]>([]);
// `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<string | null>(null);
const [searchNote, setSearchNote] = useState<string | null>(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<string, unknown>;
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 (
<Dialog open={open} onOpenChange={(next) => {
if (saving) return;
onOpenChange(next);
}}>
<DialogContent className="sm:max-w-xl max-w-xl">
<DialogHeader>
<DialogTitle>Create invoice in QuickBooks</DialogTitle>
<DialogDescription>
Pick the billing company, check the order number, then pick
the QuickBooks customer to invoice.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Billing company */}
<div className="space-y-2">
<Label>Billing company</Label>
<div className="flex gap-2">
{(["CNL", "ISHERWOOD"] as const).map((opt) => {
const selected = qbCompany === opt;
return (
<button
key={opt}
type="button"
onClick={() => setQbCompany(opt)}
disabled={saving}
data-testid={`dn-invoice-company-${opt}`}
className={cn(
"flex-1 rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:opacity-50",
selected
? "border-primary bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent",
)}
>
{opt === "CNL" ? "CNL" : "Isherwood"}
</button>
);
})}
</div>
</div>
{/* 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. */}
<div className="space-y-2">
<Label htmlFor="dn-invoice-order-number">
Invoice order number
</Label>
<Input
id="dn-invoice-order-number"
value={orderNumberOverride}
onChange={(e) => setOrderNumberOverride(e.target.value)}
placeholder="e.g. PO-9931 or the customer's order number"
disabled={saving}
data-testid="dn-invoice-order-number"
/>
<p className="text-xs text-muted-foreground">
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.
</>
)}
</p>
</div>
{/* QuickBooks customer */}
<div className="space-y-2">
<Label htmlFor="dn-invoice-qb-query">QuickBooks customer</Label>
<Input
id="dn-invoice-qb-query"
value={customerQuery}
onChange={(e) => 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"
/>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void runSearch()}
disabled={saving || customersLoading || !customerQuery.trim()}
data-testid="dn-invoice-search"
>
Search
</Button>
</div>
<div className="max-h-56 overflow-y-auto rounded-md border">
{customersLoading ? (
<p className="p-3 text-center text-sm text-muted-foreground">
Searching QuickBooks…
</p>
) : customers.length === 0 ? (
<p className="p-3 text-center text-sm text-muted-foreground">
{searchNote ?? "No QuickBooks customers match."}
</p>
) : (
customers.map((c) => {
const selected = customer?.id === c.id;
return (
<button
key={c.id}
type="button"
onClick={() => setCustomer(c)}
disabled={saving}
aria-label="QuickBooks customer"
aria-pressed={selected}
data-testid={`dn-invoice-customer-${c.id}`}
className="flex w-full items-center gap-2 border-b px-3 py-2 text-left text-sm last:border-b-0 hover:bg-accent disabled:opacity-50"
>
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full border",
selected
? "border-info-foreground bg-info-foreground text-info"
: "border-muted-foreground/40 bg-background text-transparent",
)}
>
<Check className="h-3.5 w-3.5" strokeWidth={3} />
</span>
<span className="flex-1 truncate font-medium">{c.name}</span>
</button>
);
})
)}
</div>
{!orderNumber.trim() && (
<p className="flex items-start gap-2 text-xs text-warn">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
No order number yet — you won&apos;t be able to send
until you type one above.
</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={saving}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => void submit()}
disabled={saving || !customer}
data-testid="dn-invoice-send"
>
{saving ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : null}
Send invoice + close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}