diff --git a/src/app/(app)/delivery-notes/[id]/page.tsx b/src/app/(app)/delivery-notes/[id]/page.tsx index 9cdc329..2b57978 100644 --- a/src/app/(app)/delivery-notes/[id]/page.tsx +++ b/src/app/(app)/delivery-notes/[id]/page.tsx @@ -13,6 +13,7 @@ import { Loader2, MailCheck, Pencil, + RefreshCw, Receipt, Truck, Upload, @@ -409,6 +410,60 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) { } }, [deliveryNote, loadAll, cancelModal.note]); + // Re-render the DN PDF from the current order_items + header + // relations, then save it to each item's attachments field so + // subsequent prints / downloads / preview-PDF calls succeed. + // + // The action button is exposed unconditionally on every DN — + // it's a no-op visually on DNs that already have a fresh PDF + // (the operator just gets a "regenerated" toast), and a + // repair button on older DNs that predate the save-on-create + // flow (e.g. anything from the 2026-08-13 morning session). + // Same single-flight guard pattern as the other actions so + // the operator can't double-click and queue two renders. + const handleRegeneratePdf = useCallback(async () => { + if (!deliveryNote || busyAction !== null) return; + setBusyAction("regenerate_pdf"); + try { + const res = await fetch( + `/api/v1/delivery-notes/${deliveryNote.id}/regenerate-pdf`, + { method: "POST" }, + ); + if (!res.ok) { + let message = `Failed to regenerate PDF (${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 { + /* non-json */ + } + toast.error(message); + return; + } + toast.success( + `PDF regenerated for ${deliveryNote.delivery_note_number}.`, + ); + // The Preview-PDF iframe / download link reads from the + // saved copy on disk; force-reload so the next open picks + // up the new bytes (browsers cache iframes aggressively). + setPreviewOpen(false); + await loadAll(); + } catch (err: unknown) { + toast.error( + err instanceof Error ? err.message : "Failed to regenerate PDF", + ); + } finally { + setBusyAction(null); + } + }, [deliveryNote, busyAction, loadAll]); + // The signed copy is what the customer signs and sends back // — when the "Send to customer" flow runs, this file is // attached to the outbound email (instead of the re-rendered @@ -639,6 +694,27 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) { Preview PDF + {/* Re-render + re-save the DN PDF. Useful when the saved + copy is missing (older DNs created before the + save-on-create flow shipped, e.g. DN26081307) or stale + (operator edited items after the original save). Same + single-flight guard as the other actions — disabled + while any other action is in flight. */} + {/* Upload signed copy. Distinct button styling (default, not outline) so the operator's eye lands on it when the DN is ready to be sent. diff --git a/src/app/api/v1/delivery-notes/[id]/regenerate-pdf/route.ts b/src/app/api/v1/delivery-notes/[id]/regenerate-pdf/route.ts new file mode 100644 index 0000000..9b05cf0 --- /dev/null +++ b/src/app/api/v1/delivery-notes/[id]/regenerate-pdf/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { regenerateDeliveryNotePdf } from "@/lib/server/delivery-notes-service"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * POST /api/v1/delivery-notes/[id]/regenerate-pdf + * + * Re-render the DN PDF from the current order_items + header + * relations and re-save it to each item's `order_item_attachements` + * field. Called by the "Regenerate PDF" button on the detail page + * when the saved PDF is missing (older DNs created before the + * save-on-create flow shipped) or stale (operator edited items + * after the original save and wants the printed copy to match). + * + * After this succeeds, `/api/invoicing/delivery-note-pdf` finds + * the saved copy and serves it normally — no need to keep the + * regenerate endpoint in the read path. + * + * Returns: 200 — { ok: true, pdfSaved: true, filename } + * 404 — { error } when the DN id doesn't resolve + * 500 — { error } on render / upload failure + */ +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + try { + const result = await regenerateDeliveryNotePdf(id); + return NextResponse.json({ ok: true, ...result }); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to regenerate PDF"; + // Map a "no items found" or "record not found" to 404 so the + // page can show "delivery note not found" instead of a generic + // 500. PB's getOne throws a ClientResponseError on 404. + const status = + /not found|404|No order items found/i.test(message) ? 404 : 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/lib/server/delivery-notes-service.ts b/src/lib/server/delivery-notes-service.ts index 70d83c6..5b0bb3b 100644 --- a/src/lib/server/delivery-notes-service.ts +++ b/src/lib/server/delivery-notes-service.ts @@ -35,6 +35,7 @@ import { getAdminPocketBase } from "./pocketbase-admin"; import { generateQuoteNumberServer } from "./quotes-service"; import { renderDeliveryNotePdf } from "@/lib/pdf/render"; +import { resolveDeliveryNoteLineQty } from "@/lib/pdf/delivery-note-document"; import { fetchLetterhead, letterheadForCompany, @@ -694,6 +695,11 @@ export interface AdHocDeliveryNoteResult { * - one `delivery_notes` header (status="draft") * - one `order_items` row per input item, stamped with the * new DN number and `order: null` (no parent order — ad-hoc) + * - the DN PDF rendered + uploaded to every item's + * `order_item_attachements` field, so the operator can hit + * "Print" / "Download" immediately and `/api/invoicing/delivery-note-pdf` + * finds it. This mirrors what Path A (`/api/invoicing/delivery-note`) + * does after the status flip. * * The items are stamped with `item_status: "delivery_note"` * immediately (the delivery-note lifecycle for ad-hoc items @@ -751,8 +757,9 @@ export async function createAdHocDeliveryNote( // ── 4. Create the order_items rows ──────────────────────────── // Each item is ad-hoc (no parent order) and stamped with the // DN number + the delivery-note status. We do this in parallel - // since the items are independent writes. - await Promise.all( + // since the items are independent writes. The created rows are + // captured so step 5 can attach the PDF to each one. + const createdItems = await Promise.all( input.items.map((it) => pb.collection(ORDER_ITEMS).create({ order: null, @@ -775,8 +782,227 @@ export async function createAdHocDeliveryNote( ), ); + // ── 5. Render + upload the DN PDF ───────────────────────────── + // Mirrors `/api/invoicing/delivery-note` step 2: render the PDF + // and attach it to every item's `order_item_attachements` field + // so the print / download buttons on the detail page can find it. + // Without this step, the user lands on the detail page, clicks + // Download, and gets "No saved PDF for this delivery note" because + // no item has anything in the attachments field. + // + // The PDF needs a company name (for the letterhead) and a customer + // name (for the meta block). For ad-hoc DNs there's no parent + // order to derive either from, so we resolve them via the + // relations on the header. Failures here are non-fatal — the + // header + items are already saved; the operator can hit the + // print route which falls back to regenerating on the fly. + try { + const [customerRow, companyRow] = await Promise.all([ + pb.collection(CUSTOMERS).getOne(input.customer).catch(() => null), + input.company + ? pb.collection(COMPANIES).getOne(input.company).catch(() => null) + : Promise.resolve(null), + ]); + const customerName = + typeof (customerRow as { customer?: unknown } | null)?.customer === "string" + ? ((customerRow as { customer: string }).customer) + : ""; + const companyName = + typeof (companyRow as { company?: unknown } | null)?.company === "string" + ? ((companyRow as { company: string }).company) + : ""; + + const letterheadCode = letterheadForCompany(companyName) ?? "CNL"; + const letterhead = await fetchLetterhead(letterheadCode); + const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode); + + const pdfBuffer = await renderDeliveryNotePdf({ + header: { + deliveryNoteNumber, + dateIso: new Date().toISOString().slice(0, 10), + orderNumber: "", // ad-hoc — no parent order + companyName, + customerName, + }, + lines: createdItems.map((it) => ({ + id: it.id, + description: (it.description ?? "").trim() || "(no description)", + qty: resolveDeliveryNoteLineQty({ + orderQty: it.order_qty, + qtyCollected: it.qty_collected, + }), + })), + letterhead, + logoDataUri, + }); + + const filename = `${deliveryNoteNumber}.pdf`; + const blob = new Blob([new Uint8Array(pdfBuffer)], { + type: "application/pdf", + }); + await Promise.all( + createdItems.map(async (it) => { + const form = new FormData(); + form.append("order_item_attachements", blob, filename); + await pb.collection(ORDER_ITEMS).update(it.id, form); + }), + ); + } catch (pdfErr) { + // Non-fatal — surface in the server log so the operator can + // regenerate the PDF via the print route's fallback (which + // re-renders from the order_items rows when no saved copy is + // present), but don't fail the whole create. + console.error( + "[createAdHocDeliveryNote] PDF save failed (non-fatal):", + pdfErr, + ); + } + return { id: header.id, delivery_note_number: deliveryNoteNumber, }; } + +/** + * Regenerate (and re-save) the DN PDF for an existing delivery note. + * + * Re-renders the PDF from the current `order_items` rows + the + * header relations, then uploads the bytes to each item's + * `order_item_attachements` field so the print / download / + * preview-PDF endpoints can serve it. + * + * Why this exists: the older `createAdHocDeliveryNote` flow + * created header + items but never saved a PDF, so any DN + * created before the save-on-create fix landed (e.g. `DN26081307` + * from the 2026-08-13 morning session) gets a `404 No saved PDF` + * from `/api/invoicing/delivery-note-pdf`. The button on the + * detail page that calls this lets the operator regenerate the + * PDF on demand. After it succeeds, the saved copy takes over + * and the regular endpoints serve it. + * + * Works for any DN — old or new. If the operator has edited + * items (e.g. re-priced a line) since the original PDF was + * saved, this picks up the latest state. + * + * Errors: + * - 404 (route layer) — DN id not found + * - 500 — render or upload failure + */ +export async function regenerateDeliveryNotePdf( + deliveryNoteId: string, +): Promise<{ pdfSaved: true; filename: string }> { + const pb = await getAdminPocketBase(); + + const dn = await pb + .collection(DELIVERY_NOTES) + .getOne(deliveryNoteId); + const deliveryNoteNumber = dn.delivery_note_number; + + // Pull the items for this DN. Same expand pattern as the print + // route's fallback render — we need order.customer / company + // for the PDF meta block, and the items themselves for the + // line rows. Ad-hoc DNs (no parent order) work fine here: + // `expand.order` will be undefined and we fall back to the + // header's customer / company relations. + const items = await pb + .collection(ORDER_ITEMS) + .getFullList({ + filter: `delivery_note_number = "${deliveryNoteNumber}"`, + expand: "purchase_order.supplier,order.company,order.customer", + }); + if (items.length === 0) { + throw new Error( + `No order items found for delivery note ${deliveryNoteNumber}.`, + ); + } + + // Resolve customer / company names. Prefer the order's expand + // (Path A — items have a parent order), fall back to the + // header's customer / company relations (Path B — ad-hoc). + const firstOrder = items[0].expand?.order; + const customerName = + firstOrder?.expand?.customer?.customer ?? + (await resolveRelationName(pb, dn.customer, CUSTOMERS, "customer")) ?? + ""; + const companyName = + firstOrder?.expand?.company?.company ?? + (await resolveRelationName(pb, dn.company, COMPANIES, "company")) ?? + ""; + + const letterheadCode = letterheadForCompany(companyName) ?? "CNL"; + const [letterhead, logoDataUri] = await Promise.all([ + fetchLetterhead(letterheadCode), + loadLetterheadLogoDataUri(letterheadCode), + ]); + + const pdfBuffer = await renderDeliveryNotePdf({ + header: { + deliveryNoteNumber, + dateIso: + dn.delivery_date || + new Date(dn.created || Date.now()).toISOString().slice(0, 10), + orderNumber: firstOrder?.order_number ?? "", + companyName, + customerName, + }, + lines: items.map((it) => ({ + id: it.id, + description: (it.description ?? "").trim() || "(no description)", + qty: resolveDeliveryNoteLineQty({ + orderQty: it.order_qty, + qtyCollected: it.qty_collected, + }), + })), + letterhead, + logoDataUri, + }); + + // Same upload pattern as `/api/invoicing/delivery-note` step 2: + // PB's file field PATCH REPLACES the existing list with whatever + // we send, so a re-regenerate cleanly overwrites the previous + // copy. Same filename → the retrieval endpoint (which looks for + // a file named `{deliveryNoteNumber}.pdf` on the first item) + // just picks up the new bytes. + const filename = `${deliveryNoteNumber}.pdf`; + const blob = new Blob([new Uint8Array(pdfBuffer)], { + type: "application/pdf", + }); + await Promise.all( + items.map(async (it) => { + const form = new FormData(); + form.append("order_item_attachements", blob, filename); + await pb.collection(ORDER_ITEMS).update(it.id, form); + }), + ); + + return { pdfSaved: true, filename }; +} + +/** + * Look up a single relation's display field. Used by + * `regenerateDeliveryNotePdf` to fall back to the header's + * customer / company relations when the items don't have a + * parent order (ad-hoc / Path B delivery notes). + * + * Returns null when the relation is unset, the id is missing, + * or the lookup fails. The caller treats null as "blank" and + * the PDF meta block renders an empty cell. + */ +async function resolveRelationName( + pb: Awaited>, + relationId: string | null | undefined, + collection: string, + field: string, +): Promise { + if (!relationId) return null; + try { + const row = (await pb.collection(collection).getOne(relationId)) as { + [k: string]: unknown; + }; + const value = row[field]; + return typeof value === "string" && value.length > 0 ? value : null; + } catch { + return null; + } +}