diff --git a/src/lib/pdf/render.test.ts b/src/lib/pdf/render.test.ts index 7fee91c..a4d8dce 100644 --- a/src/lib/pdf/render.test.ts +++ b/src/lib/pdf/render.test.ts @@ -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); }); \ No newline at end of file diff --git a/src/lib/pdf/render.tsx b/src/lib/pdf/render.tsx index 0170609..3be6034 100644 --- a/src/lib/pdf/render.tsx +++ b/src/lib/pdf/render.tsx @@ -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 `` to a PDF Buffer. @@ -100,4 +108,34 @@ export async function renderDeliveryNotePdf(props: DeliveryNoteDocumentProps): P const instance = pdf(); 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().toString()` + * pipeline so the test suite runs under jsdom without dragging in + * a Chromium binary. + */ +export async function renderStockPurchaseListPdf( + props: StockPurchaseListDocumentProps +): Promise { + const instance = pdf(); + 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 { + const instance = pdf(); + const pdfString = await instance.toString(); + return Buffer.from(pdfString, "binary"); } \ No newline at end of file diff --git a/src/lib/pdf/stock-item-stocktake-document.tsx b/src/lib/pdf/stock-item-stocktake-document.tsx new file mode 100644 index 0000000..04e476e --- /dev/null +++ b/src/lib/pdf/stock-item-stocktake-document.tsx @@ -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 ( + + + + Stocktake — All Active Items + + Generated {generatedAt} · {items.length} {items.length === 1 ? "item" : "items"} + + + + {items.length === 0 ? ( + + No active stock items. + + ) : ( + + + Name + SKU + Location + Unit + Current qty + Actual + Supplier + + {items.map((item) => ( + + {item.name} + {item.sku} + {item.location} + {item.unit} + {item.current_qty} + {""} + + {item.expand?.supplier?.suppliername ?? ""} + + + ))} + + )} + + + ); +} \ No newline at end of file diff --git a/src/lib/pdf/stock-purchase-list-document.tsx b/src/lib/pdf/stock-purchase-list-document.tsx new file mode 100644 index 0000000..6154d47 --- /dev/null +++ b/src/lib/pdf/stock-purchase-list-document.tsx @@ -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 = { + 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 ( + + + + Stock Orders — Full List + + Generated {generatedAt} · {rows.length} {rows.length === 1 ? "row" : "rows"} + + + + {rows.length === 0 ? ( + No stock orders. + ) : ( + + + Number + Supplier + Status + Ordered (R) + Received (R) + Items + Created + + + {rows.map((row) => ( + + {row.purchase_number} + + {row.expand?.supplier?.suppliername ?? "Unknown supplier"} + + + {STATUS_LABEL[row.status] ?? row.status} + + + {formatZAR(row.ordered_value)} + + + {formatZAR(row.received_value)} + + {row.item_count} + + {formatDateLong(row.created)} + + + + ))} + + )} + + + ); +} \ No newline at end of file