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:
parent
a5567f41cf
commit
45f1ec6bb7
4 changed files with 355 additions and 1 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
101
src/lib/pdf/stock-item-stocktake-document.tsx
Normal file
101
src/lib/pdf/stock-item-stocktake-document.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/lib/pdf/stock-purchase-list-document.tsx
Normal file
118
src/lib/pdf/stock-purchase-list-document.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue