WFDASH-WEBAPP/src/lib/pdf/delivery-note-document.tsx
Dave 4e4e7328b3 fix(delivery-note): signed copy invisible, and a bigger QR in the corner
TWO bugs, one of which was hiding the other.

1. "No signed copy on file" for a note that had just been signed,
   scanned and uploaded. The upload was never the problem — the READ
   path was. `delivery_notes.signed_copy` is a PB file field with
   `maxSelect: 1`: we WRITE it as an array (`signed_copy: [file]`),
   but PocketBase RETURNS A BARE STRING when exactly one file is
   attached. Verified on the live record after a real upload:

       typeof        : string
       Array.isArray : false
       value         : "image_ar52u2hjfq.jpg"
       value[0]      : "i"        <-- indexing yields a CHARACTER

   Every read site assumed `string[]`, and not one of them threw:
     - signed-copy GET: `Array.isArray(x) ? x[0] : null` returned null
       for notes that demonstrably had a copy — the reported error
     - delivery-note detail tooltip: `signed_copy[0]` rendered the
       first LETTER of the filename
     - delivery-notes-service: treated every signed copy as absent, so
       the quote went out with the UNSIGNED pdf attached
     - deliveries-service: reported an empty filename
     - deliveries page: `.join(", ")` on a string — this one THREW,
       on a page people open daily

   Adds `src/lib/file-fields.ts` (firstFileName / fileNames / hasFile)
   and routes all of them through it. The type was the root of the
   wrong assumptions, so it is now `string | string[]` with a comment
   pointing at the helper.

2. QR 28pt -> 52pt, moved to the top-right corner. Measured by
   rasterising the real PDF at 200 dpi and decoding: it now reads at
   1.24 px/module, where the old size needed ~2.5 — roughly double the
   distance and angle tolerance.

   It could not simply go at top:40: the logo is an absolute 160x100pt
   block in the same corner, so the QR landed on top of it. That is
   not only ugly, it breaks the QR's quiet zone — logo ink inside the
   light margin makes the code refuse to decode. The logo now steps
   left (right: 112 -> 106, width 160 -> 132) to give the QR the true
   corner, and the company name is width-bounded ONLY when a logo is
   present. The Westscott letterhead has no matching .svg in
   public/letterhead/, so in practice the name still gets the full
   width and stays on one line.

Nine tests pin the file-field shapes, including one that asserts the
old `.join(", ")` call really does throw on a string, so the string
case cannot be quietly dropped again.
2026-10-04 17:00:30 +02:00

560 lines
21 KiB
TypeScript

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 `<svg><image>`. That
* claim was wrong, and the workaround is actively harmful: a PNG
* wrapped in `<svg><image>` 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* `<svg><image>` 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 (
<Document
title={`Delivery Note ${header.deliveryNoteNumber}`}
author={letterhead.display_name}
subject={`Delivery Note for ${header.companyName}`}
>
<Page size="A4" style={styles.page}>
{/* 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. */}
<View style={styles.headerCompany}>
{/* 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. */}
<Text
style={
logoDataUri
? [styles.companyName, { width: 305 }]
: styles.companyName
}
>
{letterhead.display_name} (Pty) Ltd
</Text>
<Text style={styles.body}>{letterhead.address}</Text>
<Text style={styles.body}>{letterhead.phone}</Text>
<Text style={styles.body}>{letterhead.email}</Text>
<Text style={styles.body}>{`VAT No: ${letterhead.vat_number}`}</Text>
{letterhead.registration_number ? (
<Text style={styles.body}>{`Business ID No: ${letterhead.registration_number}`}</Text>
) : null}
</View>
{logoDataUri ? (
<View style={styles.logoAbsolute}>
<Image src={logoDataUri} style={styles.logo} />
</View>
) : 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. */}
<Text style={styles.title}>DELIVERY NOTE</Text>
{/* 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. */}
<View style={styles.metaRow}>
<View style={styles.metaCol}>
<Text style={styles.bold}>DELIVER TO:</Text>
<Text style={styles.body}>{header.companyName || "(no company)"}</Text>
<Text style={styles.body}>
{header.customerName ? `Attention: ${header.customerName}` : "(no customer)"}
</Text>
<Text style={styles.body}>
{header.orderNumber ? `Order: ${header.orderNumber}` : "(no order number)"}
</Text>
</View>
<View style={[styles.metaCol, styles.metaColRight]}>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Delivery Note No: </Text>
{header.deliveryNoteNumber}
</Text>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Date: </Text>
{formatDateLong(header.dateIso)}
</Text>
</View>
</View>
{/* 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. */}
<View style={styles.table}>
<View style={[styles.tableHeaderRow, { backgroundColor: accent }]}>
<Text style={[styles.tableHeaderCell, styles.colIndex]}>No.</Text>
<Text style={[styles.tableHeaderCell, styles.colDescription]}>Description</Text>
<Text style={[styles.tableHeaderCell, styles.colQty, styles.alignRight]}>Qty</Text>
</View>
{lines.map((line, idx) => (
<View
key={line.id || idx}
style={[styles.tableRow, ...(idx % 2 === 1 ? [styles.tableRowAlt] : [])]}
>
<Text style={[styles.tableCell, styles.colIndex]}>{idx + 1}</Text>
<Text style={[styles.tableCell, styles.colDescription]}>
{line.description || "(no description)"}
</Text>
<Text style={[styles.tableCell, styles.colQty, styles.alignRight]}>{line.qty}</Text>
</View>
))}
</View>
{/* 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. */}
<View style={styles.totals}>
<View style={styles.totalRule} />
<View style={styles.totalLine}>
<Text style={styles.totalFinalLabel}>Total Items:</Text>
<Text style={styles.totalFinalValue}>{totalQty}</Text>
</View>
</View>
{/* 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. */}
<View style={styles.signatureSection}>
<View style={styles.signatureRow}>
<View style={styles.signatureCol}>
<Text style={styles.signatureTitle}>Received By</Text>
{/* 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 ? (
<>
<Image style={styles.signatureImage} src={signatureImage} />
<View style={styles.signatureRule} />
</>
) : (
<View style={styles.signatureLine} />
)}
<Text style={styles.signatureLabel}>Name & signature</Text>
<View style={styles.signatureLineShort} />
<Text style={styles.signatureLabel}>Date</Text>
</View>
<View style={styles.signatureCol}>
<Text style={styles.signatureTitle}>Delivered By</Text>
<View style={styles.signatureLine} />
<Text style={styles.signatureLabel}>Driver name & signature</Text>
<View style={styles.signatureLineShort} />
<Text style={styles.signatureLabel}>Vehicle / Reg</Text>
</View>
</View>
</View>
{/* 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 ? (
<Text style={styles.footerText}>{letterhead.footer_text}</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 ? (
<View style={styles.qrBlock}>
<Svg
width={QR_SIZE_PT}
height={QR_SIZE_PT}
viewBox={`0 0 ${qr.size + qr.quietZone * 2} ${qr.size + qr.quietZone * 2}`}
>
<Rect
x={0}
y={0}
width={qr.size + qr.quietZone * 2}
height={qr.size + qr.quietZone * 2}
fill="#ffffff"
/>
{qr.modules.map((dark, i) =>
dark ? (
<Rect
key={i}
x={(i % qr.size) + qr.quietZone}
y={Math.floor(i / qr.size) + qr.quietZone}
width={1}
height={1}
fill="#000000"
/>
) : null,
)}
</Svg>
<Text style={styles.qrLabel}>Scan for delivery note</Text>
</View>
) : null}
</Page>
</Document>
);
}
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;
}