feat(delivery-notes): link a manual invoice, and show the invoice without leaving
Two requests. The second turned out to be much smaller than it
sounded.
## 1. Link an existing invoice (the backlog)
Hundreds of delivery notes were invoiced by hand before this app
existed. The invoice is already in QuickBooks; all the app needs is
the link. New "Link invoice" button beside "Create invoice" opens a
dialog that records the number and completes the note.
`POST /api/v1/delivery-notes/[id}/link-invoice` does two writes, and
the second is the one that's easy to forget:
1. `reconcileOrderItemInvoicedStatus` — stamps
`customer_invoice_number` + `invoiced_date` on the note's
order_items and moves them out of ready-to-invoice.
2. `markDeliveryNoteCompletedByInvoice` — flips the DN to
`completed` so it leaves the Active list.
Without (1) the note closes but its lines stay "ready to invoice"
forever, so the Invoicing page still shows the work as outstanding —
done in one place, unfinished in another. Items go FIRST: if that
fails the note is left open and retryable, whereas closing first
would leave a completed note with no obvious way back.
It reuses `buildReconcileRows`, the same quantity rule as the
create-in-QuickBooks path, so the invoice qty is still capped at
min(qty_collected, quantity) and a short pickup isn't stamped as
fully invoiced. A second copy of that rule is how the two paths
would disagree about what "invoiced" means.
Deliberately a SEPARATE button from "Create invoice", not a mode of
it: one fires a webhook and creates money, the other records a
number. Folding them together is how a customer gets invoiced twice.
## 2. The invoice comes back to the screen
Not a new integration at all. n8n already downloads the QuickBooks
invoice PDF and attaches it to the note's order_items as
`qb_invoice_pdf` — 1,209 items carry one today. The Invoicing page
has been printing them from PocketBase all along.
The create route now returns `invoice: { number, date, pdfFileName,
pdfRecordId }` alongside the updated DN (additive — existing callers
are untouched), and the list opens that PDF in the same preview
dialog the quote uses. Print and Download, no navigation.
The `pdfRecordId` matters: the file hangs off the ORDER_ITEM, not
the delivery note, and PB's file URL is
`/api/files/<collection>/<recordId>/<fileName>` — anchoring on the
delivery note would 404.
Verified in the browser as David Els: both buttons render in order
(Create quote · Send · Link invoice · Create invoice), the link
dialog opens on the list, and the URL never leaves /delivery-notes.
Full suite 3783 passing, tsc clean, no lint warnings.
This commit is contained in:
parent
7256de8859
commit
9a72f2e285
5 changed files with 504 additions and 4 deletions
|
|
@ -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 { LinkInvoiceDialog } from "@/components/delivery-notes/link-invoice-dialog";
|
||||
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";
|
||||
|
|
@ -824,6 +825,34 @@ export default function DeliveryNotesPage() {
|
|||
customerQuery: "",
|
||||
});
|
||||
|
||||
// "Link an existing invoice" (the backlog path) + the invoice the
|
||||
// create path just made, so she never has to leave the list to find
|
||||
// or print it.
|
||||
const [linkModal, setLinkModal] = useState<{
|
||||
open: boolean;
|
||||
noteId: string;
|
||||
number: string;
|
||||
orderNumber: string;
|
||||
}>({ open: false, noteId: "", number: "", orderNumber: "" });
|
||||
const [createdInvoice, setCreatedInvoice] = useState<{
|
||||
number: string;
|
||||
date: string;
|
||||
pdfFileName: string | null;
|
||||
/** The order_item the PDF file hangs off — PB file URLs are
|
||||
* /api/files/<collection>/<recordId>/<fileName>. */
|
||||
pdfRecordId: string | null;
|
||||
dnNumber: string;
|
||||
} | null>(null);
|
||||
|
||||
const openLinkModal = useCallback((note: DeliveryNote) => {
|
||||
setLinkModal({
|
||||
open: true,
|
||||
noteId: note.id,
|
||||
number: note.delivery_note_number,
|
||||
orderNumber: note.expand?.order?.order_number ?? "",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openInvoiceModal = useCallback((note: DeliveryNote) => {
|
||||
setInvoiceModal({
|
||||
open: true,
|
||||
|
|
@ -906,6 +935,10 @@ export default function DeliveryNotesPage() {
|
|||
// available; no quote required. Opens the QuickBooks
|
||||
// modal right here, so she never leaves the list.
|
||||
"invoice-qb": () => openInvoiceModal(note),
|
||||
// The backlog path: an invoice already exists in
|
||||
// QuickBooks, we just record the number. Separate from
|
||||
// the button above so nobody double-invoices.
|
||||
"link-invoice": () => openLinkModal(note),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -915,7 +948,10 @@ 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, openInvoiceModal, openQuoteReview, openSendModal, quoteByDnId, router],
|
||||
// `router` is no longer referenced: every action now happens in
|
||||
// place (quote review, send, invoice, link-invoice) rather than
|
||||
// by navigating.
|
||||
[busyIds, openInvoiceModal, openLinkModal, openQuoteReview, openSendModal, quoteByDnId],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
|
|
@ -1712,6 +1748,52 @@ 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. */}
|
||||
{/* Link an existing QuickBooks invoice (the backlog path). */}
|
||||
{linkModal.noteId && (
|
||||
<LinkInvoiceDialog
|
||||
key={`dn-link-invoice-${linkModal.noteId}`}
|
||||
open={linkModal.open}
|
||||
onOpenChange={(v) => setLinkModal((prev) => ({ ...prev, open: v }))}
|
||||
deliveryNoteId={linkModal.noteId}
|
||||
deliveryNoteNumber={linkModal.number}
|
||||
orderNumber={linkModal.orderNumber}
|
||||
onLinked={() => {
|
||||
setLinkModal((prev) => ({ ...prev, open: false }));
|
||||
void fetchNotes();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*
|
||||
The invoice that was just created, shown in place so she can
|
||||
print or download it without leaving the list — the same
|
||||
affordance the quote PDF popup gives. The file is already in
|
||||
PocketBase: n8n downloads the QuickBooks PDF and attaches it
|
||||
to the note's order_items as `qb_invoice_pdf`, so this is
|
||||
just surfacing a file that exists, not a new integration.
|
||||
*/}
|
||||
{createdInvoice && (
|
||||
<PdfPreviewDialog
|
||||
key={`dn-invoice-pdf-${createdInvoice.number}`}
|
||||
open={!!createdInvoice}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setCreatedInvoice(null);
|
||||
}}
|
||||
pdfUrl={
|
||||
createdInvoice.pdfFileName
|
||||
? `${process.env.NEXT_PUBLIC_POCKETBASE_URL}/api/files/order_items/${createdInvoice.pdfRecordId}/${createdInvoice.pdfFileName}`
|
||||
: ""
|
||||
}
|
||||
title={`Invoice ${createdInvoice.number}`}
|
||||
subtitle={
|
||||
createdInvoice.dnNumber
|
||||
? `${createdInvoice.dnNumber} · ${createdInvoice.date}`
|
||||
: createdInvoice.date
|
||||
}
|
||||
downloadFilename={`Invoice-${createdInvoice.number}.pdf`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* QuickBooks invoice, in place on the list. */}
|
||||
{invoiceModal.noteId && (
|
||||
<QbInvoiceDialog
|
||||
|
|
@ -1723,9 +1805,31 @@ export default function DeliveryNotesPage() {
|
|||
deliveryNoteId={invoiceModal.noteId}
|
||||
orderNumber={invoiceModal.orderNumber}
|
||||
customerQuerySeed={invoiceModal.customerQuery}
|
||||
onInvoiced={() => {
|
||||
onInvoiced={(updated) => {
|
||||
setInvoiceModal((prev) => ({ ...prev, open: false }));
|
||||
void fetchNotes();
|
||||
const inv = (
|
||||
updated as {
|
||||
invoice?: {
|
||||
number?: string;
|
||||
date?: string;
|
||||
pdfFileName?: string | null;
|
||||
pdfRecordId?: string | null;
|
||||
};
|
||||
}
|
||||
)?.invoice;
|
||||
void fetchNotes().then(() => {
|
||||
// Bring the invoice back on the list instead of making
|
||||
// her go and find it in QuickBooks.
|
||||
if (inv?.number) {
|
||||
setCreatedInvoice({
|
||||
number: inv.number,
|
||||
date: inv.date ?? "",
|
||||
pdfFileName: inv.pdfFileName ?? null,
|
||||
pdfRecordId: inv.pdfRecordId ?? null,
|
||||
dnNumber: invoiceModal.number,
|
||||
});
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -282,7 +282,33 @@ export async function POST(
|
|||
sentByName: body.sentByName ?? null,
|
||||
};
|
||||
const updated = await markDeliveryNoteCompletedByInvoice(id, closeInput);
|
||||
return NextResponse.json(updated);
|
||||
|
||||
// Additive: the updated DN fields stay exactly where they were, so
|
||||
// existing callers are unaffected. `invoice` is new — it lets the
|
||||
// delivery-notes LIST show the invoice in a popup after creating it,
|
||||
// instead of the operator having to go find it in QuickBooks.
|
||||
//
|
||||
// The PDF is not fetched here. n8n already downloads it and
|
||||
// attaches it to the note's order_items as `qb_invoice_pdf` (1,200+
|
||||
// rows carry one), so we hand back that reference and the browser
|
||||
// prints it from PocketBase like any other file.
|
||||
// `rowsBilled` is a shape-only projection for reconcile; it has no
|
||||
// PDF field. Read the reference off the DN's own items.
|
||||
// The PDF is a file on the ORDER_ITEM, not on the delivery note,
|
||||
// so the browser needs that record's id to build the PB file URL
|
||||
// (`/api/files/<collection>/<recordId>/<fileName>`).
|
||||
const pdfItem = items.find((it) => it.qb_invoice_pdf);
|
||||
const pdfRef = pdfItem?.qb_invoice_pdf ?? null;
|
||||
return NextResponse.json({
|
||||
...updated,
|
||||
invoice: {
|
||||
number: invoiceNumber,
|
||||
date: qbInvoice?.date ?? new Date().toISOString().slice(0, 10),
|
||||
// A single-file field is a bare string; several is an array.
|
||||
pdfFileName: Array.isArray(pdfRef) ? (pdfRef[0] ?? null) : (pdfRef ?? null),
|
||||
pdfRecordId: pdfItem?.id ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to invoice delivery note via QuickBooks";
|
||||
|
|
|
|||
178
src/app/api/v1/delivery-notes/[id]/link-invoice/route.ts
Normal file
178
src/app/api/v1/delivery-notes/[id]/link-invoice/route.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
markDeliveryNoteCompletedByInvoice,
|
||||
} from "@/lib/server/delivery-notes-service";
|
||||
import { reconcileOrderItemInvoicedStatus } from "@/lib/server/orders-service";
|
||||
import {
|
||||
buildReconcileRows,
|
||||
type InvoicingRow,
|
||||
} from "@/app/(app)/invoicing/view-model";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
/**
|
||||
* POST /api/v1/delivery-notes/[id]/link-invoice
|
||||
*
|
||||
* Attach an invoice that ALREADY EXISTS in QuickBooks to a delivery
|
||||
* note, then close the note.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* The normal path fires the `wfdash-quickbooks-invoice-creator`
|
||||
* webhook, which creates the invoice and returns its number. But a
|
||||
* pile of delivery notes were invoiced by hand, months ago, before
|
||||
* any of this existed. For those the invoice is already sitting in
|
||||
* QuickBooks; all the app needs to do is record the link so the note
|
||||
* stops looking outstanding.
|
||||
*
|
||||
* ## What it writes
|
||||
*
|
||||
* Two things, and both matter:
|
||||
*
|
||||
* 1. `markDeliveryNoteCompletedByInvoice` — flips the DN to
|
||||
* `completed` (so it leaves the Active list and stops being
|
||||
* "outstanding") and stamps an audit banner on its notes.
|
||||
* 2. `reconcileOrderItemInvoicedStatus` — stamps
|
||||
* `customer_invoice_number` + `invoiced_date` on the DN's
|
||||
* `order_items` and moves them out of the ready-to-invoice state.
|
||||
*
|
||||
* (2) is the part that's easy to forget. Without it the DN closes but
|
||||
* its lines stay "ready to invoice" forever, so the Invoicing page
|
||||
* still shows the work as outstanding — the note would look done in
|
||||
* one place and unfinished in another.
|
||||
*
|
||||
* Body: `{ invoiceNumber, sentById?, sentByName?, invoiceDate? }`
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
let body: {
|
||||
invoiceNumber?: unknown;
|
||||
sentByName?: unknown;
|
||||
sentById?: unknown;
|
||||
invoiceDate?: unknown;
|
||||
};
|
||||
try {
|
||||
body = JSON.parse((await req.text()) || "{}");
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Request body is not valid JSON" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const invoiceNumber =
|
||||
typeof body.invoiceNumber === "string" ? body.invoiceNumber.trim() : "";
|
||||
if (!invoiceNumber) {
|
||||
// A synthetic placeholder here would be worse than refusing: this
|
||||
// field is what someone searches for when a customer queries an
|
||||
// invoice, and `QB-<dn>-<timestamp>` finds nothing in QuickBooks.
|
||||
return NextResponse.json(
|
||||
{ error: "invoiceNumber is required." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const sentByName =
|
||||
typeof body.sentByName === "string" ? body.sentByName.trim() : null;
|
||||
const invoiceDate =
|
||||
typeof body.invoiceDate === "string" && body.invoiceDate.trim()
|
||||
? body.invoiceDate.trim()
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
|
||||
try {
|
||||
const pb = await getAdminPocketBase();
|
||||
const dn = await pb.collection("delivery_notes").getOne<{
|
||||
id: string;
|
||||
delivery_note_number: string;
|
||||
status: string;
|
||||
}>(id);
|
||||
|
||||
// The note's lines, so they can be stamped too. Same selector the
|
||||
// create-in-QB route uses.
|
||||
const items = dn.delivery_note_number
|
||||
? await pb
|
||||
.collection("order_items")
|
||||
.getFullList<{
|
||||
id: string;
|
||||
description?: string;
|
||||
quantity?: number;
|
||||
qty_collected?: number;
|
||||
selling_price?: number;
|
||||
}>({
|
||||
filter: `delivery_note_number = "${dn.delivery_note_number.replace(/"/g, '\\"')}"`,
|
||||
})
|
||||
: [];
|
||||
|
||||
// Same shape + same quantity rule as the create-in-QuickBooks
|
||||
// path: the invoice qty is capped at min(qty_collected, quantity)
|
||||
// so a short pickup is never stamped as fully invoiced. Building
|
||||
// it here with a different rule would make the two paths disagree
|
||||
// about what "invoiced" means.
|
||||
const rows: InvoicingRow[] = items.map((it) => ({
|
||||
id: it.id,
|
||||
description: it.description ?? "",
|
||||
quantity: typeof it.quantity === "number" ? it.quantity : null,
|
||||
orderQty:
|
||||
typeof it.quantity === "number"
|
||||
? it.quantity
|
||||
: typeof it.qty_collected === "number"
|
||||
? it.qty_collected
|
||||
: 1,
|
||||
qtyCollected:
|
||||
typeof it.qty_collected === "number" ? it.qty_collected : null,
|
||||
sellingPrice:
|
||||
typeof it.selling_price === "number" ? it.selling_price : 0,
|
||||
companyName: "",
|
||||
excludeFromInvoice: false,
|
||||
}));
|
||||
|
||||
// Items FIRST. If this fails we have not yet closed the note, so
|
||||
// the operator can retry without a half-linked DN. The reverse
|
||||
// order would leave a completed note whose lines still read as
|
||||
// outstanding, with no obvious way back.
|
||||
let reconciled = 0;
|
||||
try {
|
||||
const result = await reconcileOrderItemInvoicedStatus({
|
||||
rows: buildReconcileRows(rows),
|
||||
customerInvoiceNumber: invoiceNumber,
|
||||
invoiceDate,
|
||||
});
|
||||
reconciled = result.rowsUpdated;
|
||||
} catch (reconcileErr) {
|
||||
console.error(
|
||||
`[link-invoice] DN ${id}: item reconcile failed; leaving the note OPEN so it can be retried.`,
|
||||
reconcileErr,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Could not stamp the invoice on the note's items. The delivery note was left open — fix the cause and try again.",
|
||||
detail:
|
||||
reconcileErr instanceof Error
|
||||
? reconcileErr.message
|
||||
: String(reconcileErr),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await markDeliveryNoteCompletedByInvoice(id, {
|
||||
invoiceNumber,
|
||||
sentByName,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ...updated, rowsLinked: reconciled });
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to link the invoice";
|
||||
const status =
|
||||
/cannot be auto-closed|cancelled|completed/i.test(message) ? 409 : 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ export type DnActionId =
|
|||
| "capture-po"
|
||||
| "mark-invoiced"
|
||||
| "invoice-qb"
|
||||
| "link-invoice"
|
||||
| "mark-complete"
|
||||
| "cancel"
|
||||
| "regenerate-pdf"
|
||||
|
|
@ -301,6 +302,24 @@ function FlowControl({
|
|||
Send
|
||||
</Button>
|
||||
|
||||
{/* Slot 4 — the backlog path. An invoice already exists in
|
||||
QuickBooks (raised by hand, months ago); this only records
|
||||
the number and completes the note. Deliberately NOT folded
|
||||
into "Create invoice": one fires a webhook and creates
|
||||
money, the other records a number. Merging them is how a
|
||||
customer gets invoiced twice. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onAction["link-invoice"]}
|
||||
disabled={off || !isLive}
|
||||
data-testid={`dn-flow-link-invoice-${note.id}`}
|
||||
title="Attach an invoice number that already exists in QuickBooks"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
>
|
||||
Link invoice
|
||||
</Button>
|
||||
|
||||
{/* Slot 3 — the customer-to-customer path, always available
|
||||
while the note is live. */}
|
||||
<Button
|
||||
|
|
|
|||
173
src/components/delivery-notes/link-invoice-dialog.tsx
Normal file
173
src/components/delivery-notes/link-invoice-dialog.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* "Link an invoice that already exists in QuickBooks."
|
||||
*
|
||||
* For the backlog. Hundreds of delivery notes were invoiced by hand
|
||||
* before this app existed; the invoice is already in QuickBooks and
|
||||
* all we need is the link, so the note stops looking outstanding.
|
||||
*
|
||||
* Deliberately separate from `QbInvoiceDialog`, which CREATES an
|
||||
* invoice. Both sit on the row, because both are legitimate "finish
|
||||
* this note" actions — but one fires a webhook and the other only
|
||||
* records a number, and conflating them would be how someone ends up
|
||||
* double-invoicing a customer.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, 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";
|
||||
|
||||
export interface LinkInvoiceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
deliveryNoteId: string;
|
||||
deliveryNoteNumber: string;
|
||||
/** The DN's order number, shown for reference (read-only). */
|
||||
orderNumber?: string;
|
||||
onLinked?: (result: { invoiceNumber: string; rowsLinked: number }) => void;
|
||||
}
|
||||
|
||||
export function LinkInvoiceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
deliveryNoteId,
|
||||
deliveryNoteNumber,
|
||||
orderNumber,
|
||||
onLinked,
|
||||
}: LinkInvoiceDialogProps) {
|
||||
const [invoiceNumber, setInvoiceNumber] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
const trimmed = invoiceNumber.trim();
|
||||
if (!trimmed) {
|
||||
toast.error("Enter the invoice number from QuickBooks.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/delivery-notes/${deliveryNoteId}/link-invoice`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ invoiceNumber: trimmed }),
|
||||
},
|
||||
);
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
rowsLinked?: number;
|
||||
};
|
||||
if (!res.ok) {
|
||||
throw new Error(body.error || `Failed (${res.status})`);
|
||||
}
|
||||
toast.success(
|
||||
`Linked invoice ${trimmed} to ${deliveryNoteNumber}. The note is now completed.`,
|
||||
);
|
||||
setInvoiceNumber("");
|
||||
onOpenChange(false);
|
||||
onLinked?.({ invoiceNumber: trimmed, rowsLinked: body.rowsLinked ?? 0 });
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to link the invoice",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Link an existing invoice</DialogTitle>
|
||||
<DialogDescription>
|
||||
For a delivery note whose invoice was already raised in
|
||||
QuickBooks. This records the number and completes the note —
|
||||
it does not create anything in QuickBooks.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Delivery note</span>
|
||||
<span className="font-mono font-medium">
|
||||
{deliveryNoteNumber}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Order</span>
|
||||
<span className="font-mono font-medium">
|
||||
{orderNumber || "— none —"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dn-link-invoice-number">
|
||||
QuickBooks invoice number
|
||||
</Label>
|
||||
<Input
|
||||
id="dn-link-invoice-number"
|
||||
value={invoiceNumber}
|
||||
onChange={(e) => setInvoiceNumber(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. INV11041"
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
data-testid="dn-link-invoice-number"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This is what someone searches for when a customer queries
|
||||
an invoice, so type the real QuickBooks number — not a
|
||||
placeholder.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="flex items-start gap-2 text-xs text-warn">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
Linking marks the note and its lines as invoiced. They will
|
||||
leave the active list and stop showing as outstanding.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
disabled={saving || !invoiceNumber.trim()}
|
||||
data-testid="dn-link-invoice-submit"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Link invoice & complete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue