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.
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
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 },
|
||
});
|