WFDASH-WEBAPP/src/lib/pdf/stock-purchase-document.tsx
SJCoetz d306b75bdb feat(stock-purchases,driver-app): add stock purchase workflow and driver pickup support
Add complete stock purchase order workflow including:
- Status tracking with sending/mail_failed states and UI labels
- Server-side PDF rendering and document storage
- File proxy API support for stock PO attachments
- Send outcome webhook callback for email notifications

Add driver app support:
- Unified stop list API for both customer deliveries and stock pickups
- Status update endpoint for collected quantities and problem reporting
- Collection map page support for stock pickup locations

Include matching PocketBase migrations, type definitions, and stock inventory fulfillment logic.
2026-07-18 22:43:50 +02:00

327 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Document, Page, View, Text, Image, StyleSheet } from "@react-pdf/renderer";
import type { Letterhead } from "@/lib/letterhead";
import type { StockPurchase, StockPurchaseItem, Supplier } from "@/lib/types";
import { formatZAR, formatDateLong } from "./format";
import { defaultAccentFor } from "@/lib/letterhead";
/**
* Props for `<StockPurchaseDocument>`. The API route resolves
* every relation and the letterhead before calling
* `renderStockPurchasePdf()` — the document itself is a pure
* function of its inputs (testable, no side effects).
*/
export interface StockPurchaseDocumentProps {
purchase: StockPurchase;
/** Already filtered to this PO's items (lines where purchase = po.id). */
items: StockPurchaseItem[];
/** Pre-expanded on the PO record (`expand.supplier`). */
supplier: Supplier;
/**
* Hard-coded letterhead — same as customer POs (always CNL).
* The operator-entity is what we are, regardless of what kind of
* PO it is. Stock POs are issued by CNL just like customer POs.
*/
letterhead: Letterhead;
/** Same SVG data URI workaround as the other PDF documents. */
logoDataUri: string | null;
/**
* PDF generation date. Server-rendered `new Date().toISOString()` at
* the time of generation. Kept as a prop so the rendered timestamp
* matches the stored `generated_at` exactly.
*/
generatedAtIso: string;
/**
* Total of the PO at render time. Snapshot from caller so it
* matches what we store on the PO record. For stock POs this is
* `sum(quantity × cost_price)` — same math as the customer PO
* (`cost_price` is the supplier-facing price, what we pay).
*/
totalAmount: number;
}
/**
* Stock Purchase Order PDF — A4 portrait, supplier-facing.
*
* Layout intentionally mirrors `PurchaseOrderDocument` (the customer-
* PO PDF) so the two feel like siblings when printed side-by-side:
* - top-left: CNL company letterhead (name + address + phone + email + VAT + reg)
* - top-right: CNL logo
* - big "STOCK PURCHASE ORDER" heading (the extra word "STOCK" is
* the only visual differentiator — supplier should never confuse
* this for a customer PO)
* - two-column:
* Left: TO: <supplier> block
* Right: PO No / Date / Status / (no Order Ref — stock POs have
* no customer order attached)
* - items table with accent-coloured header (Description / SKU / Qty /
* Cost / Line Total). The SKU column is new vs. customer POs —
* stock POs reference stock_items by SKU so the supplier can
* cross-check the line.
* - right-aligned totals (Subtotal + Total)
*
* Intentionally stripped (vs. the customer PO PDF):
* - **No Order Ref line** — stock POs have no customer order. The
* supplier must NOT learn anything about the end customer. Same
* confidentiality rule as customer POs.
* - **No banking details / no VAT line** — same rationale as the
* customer PO: we pay what the supplier quotes, no markup math.
*
* The `STK` suffix on the PO number (e.g. `PO26071601STK`) is the
* in-band signal to the supplier that this is a stock order, not a
* customer one. The heading also calls it out as "STOCK PURCHASE
* ORDER" to make the distinction unambiguous.
*
* Render with `@react-pdf/renderer`'s `pdf()` in the API route.
*/
export function StockPurchaseDocument({
purchase,
items,
supplier,
letterhead,
logoDataUri,
generatedAtIso,
totalAmount,
}: StockPurchaseDocumentProps) {
const accent =
letterhead.accent_color ||
defaultAccentFor(
letterhead.display_name?.toUpperCase().includes("CNL") ? "CNL" : "ISHERWOOD"
);
// Subtotal = sum of (qty × cost) per line. We round to 2 dp so the
// table doesn't show floating-point dust (1.0000000003 R etc).
const subtotal = Math.round(
items.reduce(
(sum, it) => sum + (Number(it.quantity) || 0) * (Number(it.cost_price) || 0),
0
) * 100
) / 100;
// Re-use the supplied snapshot if it differs from the live
// computation by < 0.01 — caller might have included an explicit
// total_amount that we want to honour (e.g. from the regenerate
// API where the PO record already has total_amount set).
const total = Math.abs(totalAmount - subtotal) < 0.01 ? totalAmount : subtotal;
return (
<Document
title={`Stock Purchase Order ${purchase.purchase_number}`}
author={letterhead.display_name}
subject={`Stock PO for ${supplier.suppliername}`}
>
<Page size="A4" style={styles.page}>
{/* Header — same floating-logo pattern as the quote PDF to
avoid the @react-pdf flexbox-Image placement bug. */}
<View style={styles.headerCompany}>
<Text style={styles.companyName}>{letterhead.display_name} (Pty) Ltd</Text>
<Text style={styles.body}>{letterhead.address}</Text>
<Text style={styles.body}>{letterhead.phone}</Text>
<Text style={styles.body}>{letterhead.email}</Text>
<Text style={styles.body}>VAT No: {letterhead.vat_number}</Text>
{letterhead.registration_number ? (
<Text style={styles.body}>Business ID No: {letterhead.registration_number}</Text>
) : null}
</View>
{logoDataUri ? (
<View style={styles.logoAbsolute}>
<Image src={logoDataUri} style={styles.logo} />
</View>
) : null}
{/* Heading — "STOCK PURCHASE ORDER" instead of "PURCHASE ORDER"
so the supplier immediately knows this is a stock
replenishment, not a customer fulfilment. The STK suffix
on the PO number reinforces it. */}
<Text style={styles.title}>STOCK PURCHASE ORDER</Text>
{/* TO + PO meta two-column row. Left column is the supplier
(we're sending TO them); right column is the PO metadata.
NOTE: deliberately NO "For: <customer>" line — supplier
must not see who the goods are ultimately for. Stock POs
have no customer in the loop at all. */}
<View style={styles.metaRow}>
<View style={styles.metaCol}>
<Text style={styles.bold}>TO:</Text>
<Text style={styles.body}>{supplier.suppliername}</Text>
{supplier.supplieraddress ? (
<Text style={styles.body}>{supplier.supplieraddress}</Text>
) : null}
{supplier.email ? <Text style={styles.body}>{supplier.email}</Text> : null}
{supplier.suppliernumber ? (
<Text style={styles.body}>{supplier.suppliernumber}</Text>
) : null}
</View>
<View style={[styles.metaCol, styles.metaColRight]}>
<Text style={styles.metaLine}>
<Text style={styles.bold}>PO No: </Text>
{purchase.purchase_number}
</Text>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Date: </Text>
{formatDateLong(generatedAtIso)}
</Text>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Status: </Text>
{purchase.status || "draft"}
</Text>
</View>
</View>
{/* Items table. SKU column is new vs. customer POs — the
supplier cross-references the line by SKU when receiving.
Description falls through to "—" if blank so we never print
an empty cell. */}
<View style={styles.table}>
<View style={[styles.tableHeaderRow, { backgroundColor: accent }]}>
<Text style={[styles.tableHeaderCell, styles.colDescription]}>Description</Text>
<Text style={[styles.tableHeaderCell, styles.colSku]}>SKU</Text>
<Text style={[styles.tableHeaderCell, styles.colQty, styles.alignRight]}>Qty</Text>
<Text style={[styles.tableHeaderCell, styles.colPrice, styles.alignRight]}>
Unit Cost
</Text>
<Text style={[styles.tableHeaderCell, styles.colTotal, styles.alignRight]}>
Line Total
</Text>
</View>
{items.map((item, idx) => {
const qty = Number(item.quantity) || 0;
const cost = Number(item.cost_price) || 0;
const lineTotal = Math.round(qty * cost * 100) / 100;
const desc = (item.description || "").trim() || "—";
// SKU is optional — the line might be a free-text description
// the operator typed when creating the PO. Render blank
// cells as a dash so the table doesn't look broken.
const sku =
(item as StockPurchaseItem & { expand?: { stock_item?: { sku?: string } } })
.expand?.stock_item?.sku || "—";
return (
<View
key={item.id || idx}
style={[styles.tableRow, ...(idx % 2 === 1 ? [styles.tableRowAlt] : [])]}
>
<Text style={[styles.tableCell, styles.colDescription]}>{desc}</Text>
<Text style={[styles.tableCell, styles.colSku]}>{sku}</Text>
<Text style={[styles.tableCell, styles.colQty, styles.alignRight]}>{qty}</Text>
<Text style={[styles.tableCell, styles.colPrice, styles.alignRight]}>
{formatZAR(cost)}
</Text>
<Text style={[styles.tableCell, styles.colTotal, styles.alignRight]}>
{formatZAR(lineTotal)}
</Text>
</View>
);
})}
</View>
{/* Totals — just Subtotal + Total. No VAT (we're buying, the
supplier's price is what we pay). Rule above Total mirrors
the customer-PO totals block so the two feel like siblings. */}
<View style={styles.totals}>
<View style={styles.totalLine}>
<Text style={styles.totalLabel}>Subtotal:</Text>
<Text style={styles.totalValue}>{formatZAR(subtotal)}</Text>
</View>
<View style={styles.totalRule} />
<View style={styles.totalLine}>
<Text style={styles.totalFinalLabel}>Total:</Text>
<Text style={styles.totalFinalValue}>{formatZAR(total)}</Text>
</View>
</View>
</Page>
</Document>
);
}
/**
* Styles — kept in lockstep with `purchase-order-document.tsx` so the
* two PDFs feel like siblings when printed side-by-side. If you
* change a style here, mirror it in purchase-order-document.tsx
* (and vice-versa).
*
* The SKU column is a new flex size (`colSku`) — the other columns
* are sized identically so the totals column lands in the same X
* position across both PDFs.
*/
const styles = StyleSheet.create({
page: {
paddingTop: 40,
paddingHorizontal: 40,
paddingBottom: 40,
fontFamily: "Helvetica",
fontSize: 10,
color: "#222",
},
headerCompany: { marginBottom: 6 },
companyName: { fontSize: 16, fontFamily: "Helvetica-Bold", marginBottom: 6 },
body: { fontSize: 10, marginBottom: 2 },
bold: { fontFamily: "Helvetica-Bold" },
logoAbsolute: {
position: "absolute",
top: 40,
right: 40,
},
logo: { width: 160, height: 100 },
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 },
colDescription: { flex: 3.5 },
colSku: { flex: 1.2 },
colQty: { flex: 0.8 },
colPrice: { flex: 1.3 },
colTotal: { flex: 1.3 },
alignRight: { textAlign: "right" },
totals: {
alignSelf: "flex-end",
marginTop: 10,
minWidth: 220,
},
totalLine: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 3,
},
totalLabel: { fontFamily: "Helvetica-Bold", fontSize: 10 },
totalValue: { fontSize: 10 },
totalRule: {
borderTopWidth: 0.8,
borderTopColor: "#222",
marginVertical: 4,
},
totalFinalLabel: { fontFamily: "Helvetica-Bold", fontSize: 13 },
totalFinalValue: { fontFamily: "Helvetica-Bold", fontSize: 13 },
});