feat(pdf): list-pdf documents for stock orders + stocktake

Two new @react-pdf/renderer documents backed by renderStockPurchaseListPdf
and renderStockItemStocktakePdf in render.tsx. The Orders PDF mirrors
the on-screen table; the Stocktake PDF adds a blank 'Actual' column
for the operator to fill in during the walk-through. Both inherit
the toString() → Buffer pattern so they run under the same jsdom
test setup as the other PDF renderers.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 16:13:14 +02:00
parent a5567f41cf
commit 45f1ec6bb7
4 changed files with 355 additions and 1 deletions

View file

@ -1,7 +1,14 @@
import { describe, it, expect } from "vitest";
import { renderQuotePdf, renderDeliveryNotePdf } from "./render";
import {
renderQuotePdf,
renderDeliveryNotePdf,
renderStockPurchaseListPdf,
renderStockItemStocktakePdf,
} from "./render";
import type { Letterhead } from "@/lib/letterhead";
import type { Customer, Quote, QuoteItem } from "@/lib/types";
import type { StockPurchaseListRow } from "@/lib/server/stock-purchases-service";
import type { StockItem } from "@/lib/server/stock-items-service";
/**
* Smoke tests for the PDF renderer. We don't render visually — we
@ -295,4 +302,94 @@ describe("renderDeliveryNotePdf", () => {
});
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
});
describe("renderStockPurchaseListPdf", () => {
const baseRow: StockPurchaseListRow = {
id: "sp1",
collectionId: "pbc_stock_purchases",
collectionName: "stock_purchases",
created: "2026-08-01T09:00:00.000Z",
updated: "2026-08-01T09:00:00.000Z",
purchase_number: "PO26000001STK",
supplier: "sup1",
status: "draft",
notes: "",
email_error: "",
email_message_id: "",
item_count: 3,
ordered_value: 1234.56,
received_value: 0,
expand: {
supplier: {
id: "sup1",
collectionId: "pbc_suppliers",
collectionName: "suppliers",
created: "",
updated: "",
suppliername: "Alpha Bearings",
suppliernumber: "",
supplieraddress: "",
email: "",
supply_tags: "",
coordinates: "",
closingtime: "",
},
},
};
it("produces a Buffer with a PDF magic-number header", async () => {
const buf = await renderStockPurchaseListPdf({
rows: [baseRow],
generatedAtIso: "2026-08-01T09:00:00.000Z",
});
expect(Buffer.isBuffer(buf)).toBe(true);
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
it("renders an empty rows array without throwing", async () => {
const buf = await renderStockPurchaseListPdf({
rows: [],
generatedAtIso: "2026-08-01T09:00:00.000Z",
});
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
});
describe("renderStockItemStocktakePdf", () => {
const baseItem: StockItem = {
id: "si1",
collectionId: "pbc_stock_items",
collectionName: "stock_items",
created: "2026-08-01T09:00:00.000Z",
updated: "2026-08-01T09:00:00.000Z",
sku: "SKU-001",
name: "Wheel hub kit — 6-bolt pattern",
description: "",
unit: "ea",
location: "A1",
current_qty: 12,
min_qty: 2,
target_qty: 20,
supplier: "sup1",
is_active: true,
notes: "",
};
it("produces a Buffer with a PDF magic-number header", async () => {
const buf = await renderStockItemStocktakePdf({
items: [baseItem],
generatedAtIso: "2026-08-01T09:00:00.000Z",
});
expect(Buffer.isBuffer(buf)).toBe(true);
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
it("renders an empty items array without throwing", async () => {
const buf = await renderStockItemStocktakePdf({
items: [],
generatedAtIso: "2026-08-01T09:00:00.000Z",
});
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
});

View file

@ -12,6 +12,14 @@ import {
DeliveryNoteDocument,
type DeliveryNoteDocumentProps,
} from "./delivery-note-document";
import {
StockPurchaseListDocument,
type StockPurchaseListDocumentProps,
} from "./stock-purchase-list-document";
import {
StockItemStocktakeDocument,
type StockItemStocktakeDocumentProps,
} from "./stock-item-stocktake-document";
/**
* Server-side render of a `<QuoteDocument>` to a PDF Buffer.
@ -100,4 +108,34 @@ export async function renderDeliveryNotePdf(props: DeliveryNoteDocumentProps): P
const instance = pdf(<DeliveryNoteDocument {...props} />);
const pdfString = await instance.toString();
return Buffer.from(pdfString, "binary");
}
/**
* Server-side render of the stock-orders *list* PDF — every
* purchase in one document, not one PDF per PO. Sibling of
* `renderStockPurchasePdf` (which is the single-PO, supplier-facing
* PDF). Both walk `@react-pdf/renderer`'s `pdf(<JSX />).toString()`
* pipeline so the test suite runs under jsdom without dragging in
* a Chromium binary.
*/
export async function renderStockPurchaseListPdf(
props: StockPurchaseListDocumentProps
): Promise<Buffer> {
const instance = pdf(<StockPurchaseListDocument {...props} />);
const pdfString = await instance.toString();
return Buffer.from(pdfString, "binary");
}
/**
* Server-side render of the stocktake PDF — every active stock
* item in one document. Same in-process Node pipeline as the other
* renderers. The blank "Actual" column is intentional — the operator
* fills it in on the printed page during the walk-through.
*/
export async function renderStockItemStocktakePdf(
props: StockItemStocktakeDocumentProps
): Promise<Buffer> {
const instance = pdf(<StockItemStocktakeDocument {...props} />);
const pdfString = await instance.toString();
return Buffer.from(pdfString, "binary");
}

View file

@ -0,0 +1,101 @@
import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer";
import type { StockItem } from "@/lib/server/stock-items-service";
export interface StockItemStocktakeDocumentProps {
items: StockItem[];
/** ISO timestamp captured server-side at render time. */
generatedAtIso: string;
}
const styles = StyleSheet.create({
page: { padding: 28, fontSize: 9, fontFamily: "Helvetica" },
header: { marginBottom: 18 },
title: { fontSize: 16, fontFamily: "Helvetica-Bold", marginBottom: 4 },
subtitle: { color: "#555", fontSize: 9 },
table: { marginTop: 12 },
thead: {
flexDirection: "row",
backgroundColor: "#f1f5f9",
borderBottomWidth: 1,
borderBottomColor: "#333",
paddingVertical: 4,
paddingHorizontal: 4,
},
th: { fontFamily: "Helvetica-Bold", fontSize: 9 },
tr: {
flexDirection: "row",
borderBottomWidth: 0.5,
borderBottomColor: "#ddd",
paddingVertical: 4,
paddingHorizontal: 4,
},
td: { fontSize: 9 },
tdRight: { fontSize: 9, textAlign: "right" },
// Stocktake is for a walk-through — wider name column than the
// SKU/location/unit/qty columns so the operator can tick the
// actual on-hand qty next to the row.
cName: { width: "30%" },
cSku: { width: "12%" },
cLocation: { width: "10%" },
cUnit: { width: "8%" },
cQty: { width: "10%" },
cActual: { width: "10%" },
cSupplier: { width: "20%" },
});
/**
* Stocktake PDF — A4 portrait, every active stock item in a
* table sorted by name. The "Actual" column is intentionally blank
* — the operator fills it on the printed page during the stocktake
* walk-through and writes the difference back to the system by hand
* (or another flow, not in scope here).
*/
export function StockItemStocktakeDocument({
items,
generatedAtIso,
}: StockItemStocktakeDocumentProps) {
const generatedAt = new Date(generatedAtIso).toLocaleString();
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<Text style={styles.title}>Stocktake — All Active Items</Text>
<Text style={styles.subtitle}>
Generated {generatedAt} · {items.length} {items.length === 1 ? "item" : "items"}
</Text>
</View>
{items.length === 0 ? (
<Text style={{ marginTop: 24, color: "#666", fontSize: 10, textAlign: "center" }}>
No active stock items.
</Text>
) : (
<View style={styles.table}>
<View style={styles.thead}>
<Text style={[styles.th, styles.cName]}>Name</Text>
<Text style={[styles.th, styles.cSku]}>SKU</Text>
<Text style={[styles.th, styles.cLocation]}>Location</Text>
<Text style={[styles.th, styles.cUnit]}>Unit</Text>
<Text style={[styles.th, styles.cQty, { textAlign: "right" }]}>Current qty</Text>
<Text style={[styles.th, styles.cActual, { textAlign: "right" }]}>Actual</Text>
<Text style={[styles.th, styles.cSupplier]}>Supplier</Text>
</View>
{items.map((item) => (
<View key={item.id} style={styles.tr} wrap={false}>
<Text style={[styles.td, styles.cName]}>{item.name}</Text>
<Text style={[styles.td, styles.cSku]}>{item.sku}</Text>
<Text style={[styles.td, styles.cLocation]}>{item.location}</Text>
<Text style={[styles.td, styles.cUnit]}>{item.unit}</Text>
<Text style={[styles.tdRight, styles.cQty]}>{item.current_qty}</Text>
<Text style={[styles.tdRight, styles.cActual]}>{""}</Text>
<Text style={[styles.td, styles.cSupplier]}>
{item.expand?.supplier?.suppliername ?? ""}
</Text>
</View>
))}
</View>
)}
</Page>
</Document>
);
}

View file

@ -0,0 +1,118 @@
import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer";
import type { StockPurchaseListRow } from "@/lib/server/stock-purchases-service";
import { formatZAR, formatDateLong } from "./format";
export interface StockPurchaseListDocumentProps {
rows: StockPurchaseListRow[];
/** ISO timestamp captured server-side at render time. */
generatedAtIso: string;
}
const STATUS_LABEL: Record<string, string> = {
draft: "Draft",
sending: "Sending",
sent: "Sent",
mail_failed: "Mail failed",
received: "Received",
cancelled: "Cancelled",
};
const styles = StyleSheet.create({
page: { padding: 28, fontSize: 9, fontFamily: "Helvetica" },
header: { marginBottom: 18 },
title: { fontSize: 16, fontFamily: "Helvetica-Bold", marginBottom: 4 },
subtitle: { color: "#555", fontSize: 9 },
table: { marginTop: 12 },
thead: {
flexDirection: "row",
backgroundColor: "#f1f5f9",
borderBottomWidth: 1,
borderBottomColor: "#333",
paddingVertical: 4,
paddingHorizontal: 4,
},
th: { fontFamily: "Helvetica-Bold", fontSize: 8 },
tr: {
flexDirection: "row",
borderBottomWidth: 0.5,
borderBottomColor: "#ddd",
paddingVertical: 4,
paddingHorizontal: 4,
},
td: { fontSize: 8 },
tdRight: { fontSize: 8, textAlign: "right" },
// Column widths chosen to fit landscape A4 with breathing room.
cNumber: { width: "14%" },
cSupplier: { width: "22%" },
cStatus: { width: "11%" },
cOrdered: { width: "12%" },
cReceived: { width: "12%" },
cItems: { width: "7%" },
cCreated: { width: "12%" },
spacer: { width: "10%" },
empty: { marginTop: 24, color: "#666", fontSize: 10, textAlign: "center" },
});
/**
* Stock Orders list PDF — landscape A4, every purchase row in a
* table. Mirrors the on-screen table so a printed copy feels
* familiar to the operator. No logo (these are internal reports,
* not supplier-facing — the single-PO PDF route handles that).
*/
export function StockPurchaseListDocument({
rows,
generatedAtIso,
}: StockPurchaseListDocumentProps) {
const generatedAt = new Date(generatedAtIso).toLocaleString();
return (
<Document>
<Page size="A4" orientation="landscape" style={styles.page}>
<View style={styles.header}>
<Text style={styles.title}>Stock Orders — Full List</Text>
<Text style={styles.subtitle}>
Generated {generatedAt} · {rows.length} {rows.length === 1 ? "row" : "rows"}
</Text>
</View>
{rows.length === 0 ? (
<Text style={styles.empty}>No stock orders.</Text>
) : (
<View style={styles.table}>
<View style={styles.thead}>
<Text style={[styles.th, styles.cNumber]}>Number</Text>
<Text style={[styles.th, styles.cSupplier]}>Supplier</Text>
<Text style={[styles.th, styles.cStatus]}>Status</Text>
<Text style={[styles.th, styles.cOrdered, { textAlign: "right" }]}>Ordered (R)</Text>
<Text style={[styles.th, styles.cReceived, { textAlign: "right" }]}>Received (R)</Text>
<Text style={[styles.th, styles.cItems, { textAlign: "right" }]}>Items</Text>
<Text style={[styles.th, styles.cCreated]}>Created</Text>
<Text style={[styles.th, styles.spacer]} />
</View>
{rows.map((row) => (
<View key={row.id} style={styles.tr} wrap={false}>
<Text style={[styles.td, styles.cNumber]}>{row.purchase_number}</Text>
<Text style={[styles.td, styles.cSupplier]}>
{row.expand?.supplier?.suppliername ?? "Unknown supplier"}
</Text>
<Text style={[styles.td, styles.cStatus]}>
{STATUS_LABEL[row.status] ?? row.status}
</Text>
<Text style={[styles.tdRight, styles.cOrdered]}>
{formatZAR(row.ordered_value)}
</Text>
<Text style={[styles.tdRight, styles.cReceived]}>
{formatZAR(row.received_value)}
</Text>
<Text style={[styles.tdRight, styles.cItems]}>{row.item_count}</Text>
<Text style={[styles.td, styles.cCreated]}>
{formatDateLong(row.created)}
</Text>
<Text style={[styles.td, styles.spacer]} />
</View>
))}
</View>
)}
</Page>
</Document>
);
}