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;
+ }
+}