feat(delivery-notes): add pdf regeneration and fix ad-hoc pdf saves
Add new POST API endpoint at /api/v1/delivery-notes/[id]/regenerate-pdf to re-render and save delivery note PDFs from current line items. Add a "Regenerate PDF" button to the delivery note detail page with loading state and single-flight protection to prevent duplicate requests. Update ad-hoc delivery note creation to automatically attach PDFs to order items so print and download buttons work immediately after creation. Add helper utilities for resolving relation names and calculating line item quantities for PDF rendering.
This commit is contained in:
parent
18397bc9be
commit
4dd5898007
3 changed files with 347 additions and 2 deletions
|
|
@ -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) {
|
|||
<Eye className="mr-1 h-3.5 w-3.5" />
|
||||
Preview PDF
|
||||
</Button>
|
||||
{/* 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. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleRegeneratePdf()}
|
||||
disabled={busyAction !== null}
|
||||
data-testid={`dn-detail-regenerate-pdf-${deliveryNote.id}`}
|
||||
title="Re-render and save the delivery note PDF"
|
||||
>
|
||||
{busyAction === "regenerate_pdf" ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
Regenerate PDF
|
||||
</Button>
|
||||
{/* 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.
|
||||
|
|
|
|||
43
src/app/api/v1/delivery-notes/[id]/regenerate-pdf/route.ts
Normal file
43
src/app/api/v1/delivery-notes/[id]/regenerate-pdf/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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<OrderItem>({
|
||||
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<DeliveryNote>(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<OrderItem>({
|
||||
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<ReturnType<typeof getAdminPocketBase>>,
|
||||
relationId: string | null | undefined,
|
||||
collection: string,
|
||||
field: string,
|
||||
): Promise<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue