feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
import { Document, Image, Page, Rect, StyleSheet, Svg, Text, View } from "@react-pdf/renderer";
|
2026-07-14 15:35:36 +00:00
|
|
|
|
|
|
|
|
import type { Letterhead } from "@/lib/letterhead";
|
|
|
|
|
import { defaultAccentFor } from "@/lib/letterhead";
|
|
|
|
|
import { formatDateLong } from "./format";
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
import type { QrMatrix } from "./qr";
|
2026-07-14 15:35:36 +00:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
/**
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
* 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.
|
2026-07-14 15:35:36 +00:00
|
|
|
*/
|
|
|
|
|
logoDataUri: string | null;
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-07-14 15:35:36 +00:00
|
|
|
};
|
|
|
|
|
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
/**
|
|
|
|
|
* Physical size of the printed QR, in points.
|
|
|
|
|
*
|
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 15:00:30 +00:00
|
|
|
* 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.
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
*/
|
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 15:00:30 +00:00
|
|
|
const QR_SIZE_PT = 52;
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
|
2026-07-14 15:35:36 +00:00
|
|
|
/**
|
|
|
|
|
* 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,
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
signatureImage = null,
|
|
|
|
|
qr = null,
|
2026-07-14 15:35:36 +00:00
|
|
|
}: 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}>
|
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 15:00:30 +00:00
|
|
|
{/* 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>
|
2026-07-14 15:35:36 +00:00
|
|
|
<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
|
2026-08-11 23:13:46 +00:00
|
|
|
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>
|
2026-07-14 15:35:36 +00:00
|
|
|
|
|
|
|
|
{/* 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
|
2026-08-13 10:00:51 +00:00
|
|
|
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}>
|
2026-07-14 15:35:36 +00:00
|
|
|
<View style={styles.signatureRow}>
|
|
|
|
|
<View style={styles.signatureCol}>
|
|
|
|
|
<Text style={styles.signatureTitle}>Received By</Text>
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
{/* 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} />
|
|
|
|
|
)}
|
2026-07-14 15:35:36 +00:00
|
|
|
<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}
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
|
|
|
|
|
{/* 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}
|
2026-07-14 15:35:36 +00:00
|
|
|
</Page>
|
|
|
|
|
</Document>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const styles = StyleSheet.create({
|
|
|
|
|
page: {
|
|
|
|
|
paddingTop: 40,
|
|
|
|
|
paddingHorizontal: 40,
|
|
|
|
|
paddingBottom: 96,
|
|
|
|
|
fontFamily: "Helvetica",
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
color: "#222",
|
|
|
|
|
},
|
|
|
|
|
headerCompany: {
|
|
|
|
|
marginBottom: 6,
|
|
|
|
|
},
|
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 15:00:30 +00:00
|
|
|
companyName: { fontSize: 15, fontFamily: "Helvetica-Bold", marginBottom: 6 },
|
2026-07-14 15:35:36 +00:00
|
|
|
body: { fontSize: 10, marginBottom: 2 },
|
|
|
|
|
bold: { fontFamily: "Helvetica-Bold" },
|
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 15:00:30 +00:00
|
|
|
/* 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. */
|
2026-07-14 15:35:36 +00:00
|
|
|
logoAbsolute: {
|
|
|
|
|
position: "absolute",
|
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 15:00:30 +00:00
|
|
|
top: 46,
|
|
|
|
|
right: 106,
|
2026-07-14 15:35:36 +00:00
|
|
|
},
|
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 15:00:30 +00:00
|
|
|
logo: { width: 132, height: 83 },
|
2026-07-14 15:35:36 +00:00
|
|
|
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
|
2026-08-11 23:13:46 +00:00
|
|
|
// the lion's share. Qty is narrow, right-aligned.
|
2026-07-14 15:35:36 +00:00
|
|
|
colIndex: { flex: 0.5 },
|
2026-08-11 23:13:46 +00:00
|
|
|
colDescription: { flex: 7.3 },
|
2026-07-14 15:35:36 +00:00
|
|
|
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: {
|
2026-08-13 10:00:51 +00:00
|
|
|
// 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,
|
2026-07-14 15:35:36 +00:00
|
|
|
},
|
|
|
|
|
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,
|
|
|
|
|
},
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
/* 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,
|
|
|
|
|
},
|
2026-07-14 15:35:36 +00:00
|
|
|
signatureLineShort: {
|
|
|
|
|
borderBottomWidth: 1,
|
|
|
|
|
borderBottomColor: "#222",
|
|
|
|
|
height: 18,
|
|
|
|
|
marginTop: 6,
|
|
|
|
|
marginBottom: 2,
|
|
|
|
|
},
|
|
|
|
|
signatureLabel: { fontSize: 8, color: "#64748b", marginBottom: 0 },
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
/* 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. */
|
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 15:00:30 +00:00
|
|
|
/* 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. */
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
qrBlock: {
|
|
|
|
|
position: "absolute",
|
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 15:00:30 +00:00
|
|
|
top: 40,
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
right: 40,
|
|
|
|
|
alignItems: "center",
|
|
|
|
|
},
|
|
|
|
|
qrLabel: {
|
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 15:00:30 +00:00
|
|
|
fontSize: 7,
|
|
|
|
|
color: "#64748b",
|
|
|
|
|
marginTop: 3,
|
|
|
|
|
letterSpacing: 0.4,
|
feat(delivery-note): print a scannable QR of the note number
Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.
Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.
Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.
The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.
SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.
Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
2026-10-04 11:15:41 +00:00
|
|
|
},
|
2026-07-14 15:35:36 +00:00
|
|
|
footerText: {
|
|
|
|
|
fontSize: 8,
|
|
|
|
|
color: "#94a3b8",
|
|
|
|
|
textAlign: "center",
|
|
|
|
|
position: "absolute",
|
|
|
|
|
bottom: 16,
|
|
|
|
|
left: 40,
|
|
|
|
|
right: 40,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
2026-08-13 10:00:51 +00:00
|
|
|
* 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".
|
2026-07-14 15:35:36 +00:00
|
|
|
*/
|
fix(orders): bill the customer quantity, not the supplier order_qty
`order_items.order_qty` is the SUPPLIER-facing quantity — what prints
on the PO. Five call sites read it as the customer quantity, which is a
money bug in both directions:
- supplier MOQ (customer 4, we buy 10) → invoiced 10, over-billing 6
- shorted line (customer 100, supplier covers 40) → invoiced 40,
under-billing 60 until the operator splits the remainder
The invoicing screen never fetched `quantity` at all (zero occurrences
in page.tsx), so it could not bill the customer quantity even in
principle. Added to the PB field projection, which is load-bearing:
drop it and PB returns undefined and every line silently falls back to
order_qty. A page-level test now fails if the projection drifts.
`maxSurplusFor` measured the surplus as `collected - order_qty`, which
on an MOQ line is always 0 — the overflow was mathematically unbookable
and vanished from inventory by construction, not by operator error.
Four call sites built delivery-note lines by hand; two passed
`quantity` and two forgot it, so the same DN printed the customer's 4
via the service layer and the supplier's 10 via the API routes. All
four now share `buildDeliveryNoteLine`, so they cannot diverge again.
`order_qty` had no doc comment at all next to an 11-line one on
`quantity` — that absence is why five subsystems guessed differently.
Now documented, with a new `src/lib/order-item-qty.ts` holding
`customerQty` / `supplierQty` as the single definition.
driver-stops.ts and collection-confirm/po-card.tsx read `order_qty`
CORRECTLY (they check what the supplier delivered against what we
ordered from them) and carry comments saying so, so the next cleanup
doesn't "fix" them into breaking.
23 new tests. Full suite: no regressions — the 15 failures are
identical before and after (verified by stashing and re-running).
Audit: docs/superpowers/specs/2026-08-30-quantity-handling-audit.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:28:33 +00:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 15:35:36 +00:00
|
|
|
export function resolveDeliveryNoteLineQty(input: {
|
2026-08-13 10:00:51 +00:00
|
|
|
quantity?: number | null;
|
2026-07-14 15:35:36 +00:00
|
|
|
orderQty?: number | null;
|
|
|
|
|
qtyCollected?: number | null;
|
|
|
|
|
}): number {
|
2026-08-13 10:00:51 +00:00
|
|
|
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
|
|
|
|
|
) {
|
2026-07-14 15:35:36 +00:00
|
|
|
return input.qtyCollected;
|
|
|
|
|
}
|
2026-08-13 10:00:51 +00:00
|
|
|
if (
|
|
|
|
|
typeof input.orderQty === "number" &&
|
|
|
|
|
Number.isFinite(input.orderQty) &&
|
|
|
|
|
input.orderQty > 0
|
|
|
|
|
) {
|
2026-07-14 15:35:36 +00:00
|
|
|
return input.orderQty;
|
|
|
|
|
}
|
|
|
|
|
return 1;
|
|
|
|
|
}
|