diff --git a/src/app/(app)/delivery-notes/page.tsx b/src/app/(app)/delivery-notes/page.tsx index 28e5e4a..826ad03 100644 --- a/src/app/(app)/delivery-notes/page.tsx +++ b/src/app/(app)/delivery-notes/page.tsx @@ -20,6 +20,7 @@ import { import { CreateAdHocDeliveryNoteDialog } from "@/components/delivery-notes/create-adhoc-dialog"; import { DeliveryNoteItemsExpanded } from "@/components/delivery-notes/items-expanded"; import { DeliveryNoteActions } from "@/components/delivery-notes/action-buttons"; +import { 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///. */ + 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), }} /> @@ -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 && ( + 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 && ( + { + 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 && ( { + 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, + }); + } + }); }} /> )} diff --git a/src/app/api/v1/delivery-notes/[id]/invoice/route.ts b/src/app/api/v1/delivery-notes/[id]/invoice/route.ts index 369cae5..893cb0b 100644 --- a/src/app/api/v1/delivery-notes/[id]/invoice/route.ts +++ b/src/app/api/v1/delivery-notes/[id]/invoice/route.ts @@ -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///`). + 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"; diff --git a/src/app/api/v1/delivery-notes/[id]/link-invoice/route.ts b/src/app/api/v1/delivery-notes/[id]/link-invoice/route.ts new file mode 100644 index 0000000..086c70b --- /dev/null +++ b/src/app/api/v1/delivery-notes/[id]/link-invoice/route.ts @@ -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--` 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 }); + } +} \ No newline at end of file diff --git a/src/components/delivery-notes/action-buttons.tsx b/src/components/delivery-notes/action-buttons.tsx index 6147c1a..7a87fff 100644 --- a/src/components/delivery-notes/action-buttons.tsx +++ b/src/components/delivery-notes/action-buttons.tsx @@ -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 + {/* 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. */} + + {/* Slot 3 — the customer-to-customer path, always available while the note is live. */} + + + + + ); +} \ No newline at end of file