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 { CreateAdHocDeliveryNoteDialog } from "@/components/delivery-notes/create-adhoc-dialog";
|
||||||
import { DeliveryNoteItemsExpanded } from "@/components/delivery-notes/items-expanded";
|
import { DeliveryNoteItemsExpanded } from "@/components/delivery-notes/items-expanded";
|
||||||
import { DeliveryNoteActions } from "@/components/delivery-notes/action-buttons";
|
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 { QbInvoiceDialog } from "@/components/delivery-notes/qb-invoice-dialog";
|
||||||
import { SendQuoteModal } from "@/components/quotes/send-quote-modal";
|
import { SendQuoteModal } from "@/components/quotes/send-quote-modal";
|
||||||
import { QuoteReviewDialog } from "@/components/delivery-notes/quote-review-dialog";
|
import { QuoteReviewDialog } from "@/components/delivery-notes/quote-review-dialog";
|
||||||
|
|
@ -824,6 +825,34 @@ export default function DeliveryNotesPage() {
|
||||||
customerQuery: "",
|
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) => {
|
const openInvoiceModal = useCallback((note: DeliveryNote) => {
|
||||||
setInvoiceModal({
|
setInvoiceModal({
|
||||||
open: true,
|
open: true,
|
||||||
|
|
@ -906,6 +935,10 @@ export default function DeliveryNotesPage() {
|
||||||
// available; no quote required. Opens the QuickBooks
|
// available; no quote required. Opens the QuickBooks
|
||||||
// modal right here, so she never leaves the list.
|
// modal right here, so she never leaves the list.
|
||||||
"invoice-qb": () => openInvoiceModal(note),
|
"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>
|
</div>
|
||||||
|
|
@ -915,7 +948,10 @@ export default function DeliveryNotesPage() {
|
||||||
// here: the flow control offers quote / send / invoice only. Those
|
// here: the flow control offers quote / send / invoice only. Those
|
||||||
// two actions live on the detail page, which still renders the
|
// two actions live on the detail page, which still renders the
|
||||||
// full set from the same component.
|
// 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(
|
const columns = useMemo(
|
||||||
|
|
@ -1712,6 +1748,52 @@ export default function DeliveryNotesPage() {
|
||||||
on any row that doesn't have a quote yet. The dialog POSTs
|
on any row that doesn't have a quote yet. The dialog POSTs
|
||||||
the create itself with the operator's confirmed pricing;
|
the create itself with the operator's confirmed pricing;
|
||||||
`handleQuoteCreated` only refreshes the row behind it. */}
|
`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. */}
|
{/* QuickBooks invoice, in place on the list. */}
|
||||||
{invoiceModal.noteId && (
|
{invoiceModal.noteId && (
|
||||||
<QbInvoiceDialog
|
<QbInvoiceDialog
|
||||||
|
|
@ -1723,9 +1805,31 @@ export default function DeliveryNotesPage() {
|
||||||
deliveryNoteId={invoiceModal.noteId}
|
deliveryNoteId={invoiceModal.noteId}
|
||||||
orderNumber={invoiceModal.orderNumber}
|
orderNumber={invoiceModal.orderNumber}
|
||||||
customerQuerySeed={invoiceModal.customerQuery}
|
customerQuerySeed={invoiceModal.customerQuery}
|
||||||
onInvoiced={() => {
|
onInvoiced={(updated) => {
|
||||||
setInvoiceModal((prev) => ({ ...prev, open: false }));
|
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,
|
sentByName: body.sentByName ?? null,
|
||||||
};
|
};
|
||||||
const updated = await markDeliveryNoteCompletedByInvoice(id, closeInput);
|
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) {
|
} catch (err: unknown) {
|
||||||
const message =
|
const message =
|
||||||
err instanceof Error ? err.message : "Failed to invoice delivery note via QuickBooks";
|
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"
|
| "capture-po"
|
||||||
| "mark-invoiced"
|
| "mark-invoiced"
|
||||||
| "invoice-qb"
|
| "invoice-qb"
|
||||||
|
| "link-invoice"
|
||||||
| "mark-complete"
|
| "mark-complete"
|
||||||
| "cancel"
|
| "cancel"
|
||||||
| "regenerate-pdf"
|
| "regenerate-pdf"
|
||||||
|
|
@ -301,6 +302,24 @@ function FlowControl({
|
||||||
Send
|
Send
|
||||||
</Button>
|
</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
|
{/* Slot 3 — the customer-to-customer path, always available
|
||||||
while the note is live. */}
|
while the note is live. */}
|
||||||
<Button
|
<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