import { Document, Image, Page, Rect, StyleSheet, Svg, Text, View } from "@react-pdf/renderer"; import type { Letterhead } from "@/lib/letterhead"; import { defaultAccentFor } from "@/lib/letterhead"; import { formatDateLong } from "./format"; import type { QrMatrix } from "./qr"; export type DeliveryNoteDocumentHeader = { /** Server-generated delivery note number, e.g. "DN26071401". */ deliveryNoteNumber: string; /** ISO timestamp the DN was issued; rendered as a long-form date. */ dateIso: string; orderNumber: string; companyName: string; customerName: string; }; export type DeliveryNoteDocumentLine = { id: string; description: string; /** Quantity committed for delivery (qtyCollected → orderQty → 1). */ qty: number; }; export type DeliveryNoteDocumentProps = { header: DeliveryNoteDocumentHeader; lines: DeliveryNoteDocumentLine[]; letterhead: Letterhead; /** * Logo as a `data:image/svg+xml;base64,...` URI. * * The SVG here is because `public/letterhead/*.svg` IS an SVG source * file (see `loadLetterheadLogoDataUri`), not because raster data * needs wrapping. An earlier version of this comment claimed * "@react-pdf v4.5.1 corrupts every byte >= 0x80 in raster image * data" and recommended wrapping rasters in ``. That * claim was wrong, and the workaround is actively harmful: a PNG * wrapped in `` renders as an EMPTY box with no error, * while the same PNG passed directly draws correctly. See the note * at the top of `render.tsx` for what was actually going on. */ logoDataUri: string | null; /** * Captured signature as a `data:image/png;base64,...` URI, drawn * into the "Received By" block. Null/absent leaves the blank rule — * so every existing caller is unaffected. * * PNG, passed directly. Verified by rendering all three encodings * through this exact document: a base64 PNG data URI draws * correctly, a pure-vector SVG draws correctly, and a PNG *wrapped * inside* `` renders as an empty box with no error. */ signatureImage?: string | null; /** * QR code drawn as vector modules, bottom-right of the page. * Null/absent omits it entirely. * * Carries the delivery note number so a scanner app can resolve the * note. See `src/lib/pdf/qr.ts` for the security note — the value * is a sequential, enumerable number and this is only appropriate * for a closed internal consumer. */ qr?: QrMatrix | null; }; /** * Physical size of the printed QR, in points. * * Was 28pt and sat in the bottom-right corner; the operator found it * fiddly to line up on a phone. 52pt across a 21-module code plus the * 4-module quiet zone is ~1.8pt (~0.63mm) per module including * margin — comfortably above the ~0.5mm consumer scanners want, and * roughly double the on-page width so it is easy to frame from arm's * length. It moves to the top-right corner for the same reason: that * is where you look first, and the bottom-right was competing with * the signature block. */ const QR_SIZE_PT = 52; /** * Renders a printable delivery note PDF. Same visual language as the * quote + PO documents (letterhead top-left, logo top-right, big * title, two-column meta block, accent-coloured items table) — only * the document title and the meta-block labels change. The picking * slip that accompanies the DN is rendered separately and merged * into the print bundle by the print route. * * Render with `renderDeliveryNotePdf` in `src/lib/pdf/render.tsx`. */ export function DeliveryNoteDocument({ header, lines, letterhead, logoDataUri, signatureImage = null, qr = null, }: DeliveryNoteDocumentProps) { const accent = letterhead.accent_color || defaultAccentFor( letterhead.display_name?.toUpperCase().includes("CNL") ? "CNL" : "ISHERWOOD", ); const totalQty = lines.reduce((sum, line) => sum + (Number.isFinite(line.qty) ? line.qty : 0), 0); return ( {/* Header — company info on the left, logo floating on the right. Same absolutely-positioned logo pattern as the quote + PO documents: @react-pdf's flexbox doesn't reliably place Images inside `flexDirection: row`, so we use position: absolute with explicit top/right instead. */} {/* Bounded only when a logo is present. The logo's left edge sits at 595 - (106 + 132) = 357pt and the name starts at the 40pt margin, so 305pt leaves a clear gap. With no logo — which is the case for the Westscott letterhead, since public/letterhead/ has no matching .svg — the name gets the full width and stays on one line. */} {letterhead.display_name} (Pty) Ltd {letterhead.address} {letterhead.phone} {letterhead.email} {`VAT No: ${letterhead.vat_number}`} {letterhead.registration_number ? ( {`Business ID No: ${letterhead.registration_number}`} ) : null} {logoDataUri ? ( ) : null} {/* DELIVERY NOTE heading — same type scale as the quote ("QUOTATION") and PO ("PURCHASE ORDER") so the three documents feel like siblings when filed side-by-side. */} DELIVERY NOTE {/* DELIVER TO + DN meta two-column row. Left = recipient (the company we're delivering to + the customer + order number for cross-reference). Right = DN metadata (number + date). No "Reference" / "Valid until" lines — those don't apply to a delivery note. */} DELIVER TO: {header.companyName || "(no company)"} {header.customerName ? `Attention: ${header.customerName}` : "(no customer)"} {header.orderNumber ? `Order: ${header.orderNumber}` : "(no order number)"} Delivery Note No: {header.deliveryNoteNumber} Date: {formatDateLong(header.dateIso)} {/* Items table — same accent header + alternating-row treatment as the quote + PO. Column shape is # / Description / Qty. No prices / supplier details / line totals — a delivery note doesn't carry per-line money the way a quote/PO does; the operator hands this to the driver and the customer signs on receipt. */} No. Description Qty {lines.map((line, idx) => ( {idx + 1} {line.description || "(no description)"} {line.qty} ))} {/* Total — right-aligned, smaller than the quote "Total:" line (this is qty sum, not money). Kept on the right so the operator's eye lands on it the same way it does on a quote/PO. */} Total Items: {totalQty} {/* Signature block — two columns (received / delivered) laid out side-by-side so the printout is one tidy section. Each row has its own signature line + a date sub-line. Flows inline below the items table; if the table runs to a new page the block follows it. The picking slip is no longer stacked inside this document (it's rendered separately by the print route), so we don't need the absolute / fixed positioning that used to keep it pinned to the bottom of every page — that caused it to overlap the last few items when the table filled most of the page. */} Received By {/* Drawn signature, when one was captured. Replaces the blank rule rather than sitting on it — a blank line under a signature reads as "unsigned" at a glance, which is the opposite of what this block is for. */} {signatureImage ? ( <> ) : ( )} Name & signature Date Delivered By Driver name & signature Vehicle / Reg {/* Footer text from the letterhead (e.g. "Thank you for your business."). Same as the quote + PO — keeps the family of documents visually consistent. */} {letterhead.footer_text ? ( {letterhead.footer_text} ) : null} {/* QR, bottom-right. Vector modules, not an image — see `src/lib/pdf/qr.ts` for why that matters. The quiet zone is baked into the Svg viewBox so scanners see the light margin the spec requires even though the surrounding page has the accent-coloured table nearby. */} {qr ? ( {qr.modules.map((dark, i) => dark ? ( ) : null, )} Scan for delivery note ) : null} ); } const styles = StyleSheet.create({ page: { paddingTop: 40, paddingHorizontal: 40, paddingBottom: 96, fontFamily: "Helvetica", fontSize: 10, color: "#222", }, headerCompany: { marginBottom: 6, }, companyName: { fontSize: 15, fontFamily: "Helvetica-Bold", marginBottom: 6 }, body: { fontSize: 10, marginBottom: 2 }, bold: { fontFamily: "Helvetica-Bold" }, /* Steps LEFT to make room for the QR in the top-right corner: 40 (margin) + 52 (QR) + 14 (gap). The QR has to own the corner — it is what the phone camera is aimed at, so it should be the first thing found rather than something you have to hunt for beside the logo. The company name is unaffected: it ends well left of 215. */ logoAbsolute: { position: "absolute", top: 46, right: 106, }, logo: { width: 132, height: 83 }, title: { fontSize: 22, fontFamily: "Helvetica-Bold", marginTop: 24, marginBottom: 18, letterSpacing: 0.5, }, metaRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 18, }, metaCol: { flex: 1, paddingRight: 12 }, metaColRight: { alignItems: "flex-end" }, metaLine: { fontSize: 10, marginBottom: 4 }, table: { width: "100%", borderTopWidth: 0.5, borderTopColor: "#cccccc", }, tableHeaderRow: { flexDirection: "row", paddingVertical: 6, paddingHorizontal: 6, }, tableHeaderCell: { color: "#ffffff", fontFamily: "Helvetica-Bold", fontSize: 10, }, tableRow: { flexDirection: "row", paddingVertical: 6, paddingHorizontal: 6, borderBottomWidth: 0.5, borderBottomColor: "#dddddd", }, tableRowAlt: { backgroundColor: "#fafafa" }, tableCell: { fontSize: 10 }, // No. column — narrow, right-aligned-friendly. Description gets // the lion's share. Qty is narrow, right-aligned. colIndex: { flex: 0.5 }, colDescription: { flex: 7.3 }, colQty: { flex: 0.7 }, alignRight: { textAlign: "right" }, totals: { alignSelf: "flex-end", marginTop: 10, minWidth: 220, }, totalLine: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 3, }, totalFinalLabel: { fontFamily: "Helvetica-Bold", fontSize: 12 }, totalFinalValue: { fontFamily: "Helvetica-Bold", fontSize: 12 }, totalRule: { borderTopWidth: 0.8, borderTopColor: "#222", marginVertical: 4, }, signatureSection: { // Inline flow — sits below the items table with some // breathing room. Was previously position: absolute / // bottom: 40 to pin to the page footer, but that pinned // it on TOP of the last items when the table filled most // of the page (the visual overlap you saw). The picking // slip used to be stacked under this in the same doc and // needed the pinned footer; now that the picking slip is // rendered separately by the print route, inline flow is // correct. marginTop: 24, }, signatureRow: { flexDirection: "row", gap: 24 }, signatureCol: { flex: 1 }, signatureTitle: { fontFamily: "Helvetica-Bold", fontSize: 10, marginBottom: 16, }, signatureLine: { borderBottomWidth: 1, borderBottomColor: "#222", height: 22, marginBottom: 2, }, /* Signature sits ON the rule rather than above it — the captured PNG is transparent-background, so the line reads as the baseline it was drawn against. Fixed height so a short scribble and a long one both leave the same "Name & signature" position, which keeps both columns aligned. */ /* The captured PNG is transparent-background, so the rule reads as the baseline it was drawn against. Height is fixed so a short scribble and a long one leave "Name & signature" in exactly the same place — the two columns stay aligned and the page doesn't reflow when one is signed and the other isn't. */ signatureImage: { width: 130, height: 22 }, /* Zero-height rule for the signed case: the image supplies the 22pt of vertical space and this draws only the 1pt baseline directly under it. A full-height `signatureLine` here would leave the signature floating 22pt above the line it belongs on. */ signatureRule: { borderBottomWidth: 1, borderBottomColor: "#222", height: 0, marginBottom: 2, }, signatureLineShort: { borderBottomWidth: 1, borderBottomColor: "#222", height: 18, marginTop: 6, marginBottom: 2, }, signatureLabel: { fontSize: 8, color: "#64748b", marginBottom: 0 }, /* QR sits absolute in the bottom-right of the page body, above the absolutely-positioned footer text. 28pt on a 21-module code is ~0.96pt per module plus the 4-module quiet zone — comfortably above the ~0.5mm/module that consumer scanners need, while staying small enough not to crowd the signature block. */ /* Sits directly BELOW the logo, not on it. The logo is an absolute 160x100pt block at top:40, so 40 + 100 + 14 = 154 clears it. An earlier attempt at top:40 put the QR straight over the logo — which not only looks wrong, it breaks the QR's quiet zone: the logo's ink sits inside the light margin the spec requires, and the code will refuse to decode. */ qrBlock: { position: "absolute", top: 40, right: 40, alignItems: "center", }, qrLabel: { fontSize: 7, color: "#64748b", marginTop: 3, letterSpacing: 0.4, }, footerText: { fontSize: 8, color: "#94a3b8", textAlign: "center", position: "absolute", bottom: 16, left: 40, right: 40, }, }); /** * Resolve the quantity for a delivery-note line. The fallback * chain is `quantity → qtyCollected → orderQty → 1` so the * printed document always shows a meaningful number. * * `quantity` (the operator-edited value on the detail page) * wins — that's what the printed DN should reflect. Without * this preference the qty editor is a no-op for the printed * document: an operator edits a line to 6, hits save, and * the PDF still shows whatever `qtyCollected` / `orderQty` * says (often 1). Each level of the chain treats `0` as * "unset" and falls through, because: * - `quantity = 0` would mean "delivering nothing", * which is nonsensical for a delivery note that's * about to be printed + handed to a driver; * - `qtyCollected = 0` usually means "supplier hasn't * picked this yet" rather than "we picked zero"; * - `orderQty = 0` shouldn't happen (operator-edited), * but defensively we fall through rather than print * "deliver 0 of this thing". */ /** * Build one delivery-note line from a raw `order_items` row. * * Every renderer of DN lines must go through this. It exists because the * four call sites that used to build lines by hand did not agree: two * passed `quantity` to `resolveDeliveryNoteLineQty` and two forgot it, so * the same delivery note printed the customer's 4 through the service * layer and the supplier's 10 through the API routes. Taking the whole * row instead of hand-picked arguments removes that failure mode. * * See `docs/superpowers/specs/2026-08-30-quantity-handling-audit.md`. */ export function buildDeliveryNoteLine(item: { id: string; description?: string | null; quantity?: number | null; order_qty?: number | null; qty_collected?: number | null; }): { id: string; description: string; qty: number } { return { id: item.id, description: (item.description ?? "").trim() || "(no description)", qty: resolveDeliveryNoteLineQty({ quantity: item.quantity, orderQty: item.order_qty, qtyCollected: item.qty_collected, }), }; } export function resolveDeliveryNoteLineQty(input: { quantity?: number | null; orderQty?: number | null; qtyCollected?: number | null; }): number { if ( typeof input.quantity === "number" && Number.isFinite(input.quantity) && input.quantity > 0 ) { return input.quantity; } if ( typeof input.qtyCollected === "number" && Number.isFinite(input.qtyCollected) && input.qtyCollected > 0 ) { return input.qtyCollected; } if ( typeof input.orderQty === "number" && Number.isFinite(input.orderQty) && input.orderQty > 0 ) { return input.orderQty; } return 1; }