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:
parent
d983eccd6c
commit
970104e510
3 changed files with 474 additions and 332 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
395
src/components/delivery-notes/qb-invoice-dialog.tsx
Normal file
395
src/components/delivery-notes/qb-invoice-dialog.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue