WFDASH-WEBAPP/src/lib/pdf/purchase-order-document.tsx
2026-08-06 12:08:14 +02:00

337 lines
13 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 { OrderItem, Order, Supplier, PurchaseOrder } from "@/lib/types";
import { formatZAR, formatDateLong, poSubtotal, vatAmount } from "./format";
import { defaultAccentFor } from "@/lib/letterhead";
/**
* Props for `<PurchaseOrderDocument>`. The API route resolves
* every relation and resolves the letterhead before calling
* renderPurchaseOrderPdf() — the document itself is a pure
* function of its inputs (testable, no side effects).
*/
export interface PurchaseOrderDocumentProps {
purchaseOrder: PurchaseOrder;
/** Already filtered to this PO's items (items where purchase_order = po.id). */
items: OrderItem[];
/** Pre-expanded on the PO record (`expand.order`). */
order: Order;
/** Pre-expanded on the PO record (`expand.supplier`). */
supplier: Supplier;
/**
* Hard-coded letterhead — always CNL (the operating entity that
* issues POs), regardless of which company sits on the order.
* Keeping customer-facing company info OFF the PO is a deliberate
* privacy boundary: the supplier sees us, not the customer.
*/
letterhead: Letterhead;
/** Same SVG data URI workaround as the quote document. */
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 in `purchase_orders.total_amount`. */
totalAmount: number;
}
/**
* Purchase Order PDF — A4 portrait, supplier-facing.
*
* Layout (mirrors the quote document so the two feel like siblings):
* - top-left: CNL company letterhead (name + address + phone + email + VAT + reg)
* - top-right: CNL logo
* - big "PURCHASE ORDER" heading
* - two-column:
* Left: TO: <supplier> block (name + address + email + contact)
* Right: PO No / Date / Order Ref
* - items table with accent-coloured header (Description / Qty / Unit Cost / Line Total)
* - right-aligned totals (rule above Total)
* - **VAT (15%) included** — subtotal + VAT (15%) + rule +
* VAT-inclusive Total, same shape as the quote's totals
* block so the two PDFs feel like siblings. The printed
* `Total` is `subtotal × 1.15` — the figure the office uses
* for budget / VAT-return reconciliation. The stored
* `purchase_orders.total_amount` stays VAT-ex (no PB schema
* change).
*
* Intentionally stripped from this document (vs. the quote PDF):
* - **No banking details** — supplier invoices via the normal
* commercial channel; the PO is an instruction to supply, not
* a payment instruction.
* - **No customer name / "For:" line** — the supplier should
* not contact the customer directly; we mediate the relationship.
* The customer's identity stays internal.
* - **No footer text** — keeps the document visually quiet and
* prevents accidental leakage of branch-specific marketing copy.
*
* Description column uses `po_description` (supplier-facing copy),
* NOT `description` (internal/operator copy). Falling back to
* `description` keeps the PDF useful when no PO description was
* entered on the order.
*
* Render with `@react-pdf/renderer`'s `pdf()` in the API route.
*/
export function PurchaseOrderDocument({
purchaseOrder,
items,
order,
supplier,
letterhead,
logoDataUri,
generatedAtIso,
totalAmount,
}: PurchaseOrderDocumentProps) {
const accent =
letterhead.accent_color ||
defaultAccentFor(
(letterhead.display_name?.toUpperCase().includes("CNL") ? "CNL" : "ISHERWOOD")
);
// Subtotal = sum of effective-qty × cost per line. The "effective
// qty" fallback logic (order_qty || quantity, so a literal 0
// falls through) lives in `poSubtotal()` (src/lib/pdf/format.ts)
// so it's independently testable. The printed line totals sum
// exactly to this Subtotal.
const subtotal = poSubtotal(items);
// 15% VAT on top of the supplier-quoted subtotal — same helper the
// quote PDF uses, so both documents compute VAT identically. Total
// = subtotal + vat, rounded to 2dp so the line matches the printed
// figures exactly. The `totalAmount` prop (carried for API backwards
// compat with the existing route) is intentionally not used here:
// it holds the VAT-ex subtotal and there's nothing meaningful to
// reconcile against once the printed Total is `subtotal + vat`.
const vat = vatAmount(subtotal);
const totalInclVat = Math.round((subtotal + vat) * 100) / 100;
return (
<Document
title={`Purchase Order ${purchaseOrder.po_number}`}
author={letterhead.display_name}
subject={`PO for ${supplier.suppliername} (Order ${order.order_number})`}
>
<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 — "PURCHASE ORDER" instead of "QUOTATION". Same
type scale as the quote so the two documents feel like
siblings when printed side-by-side. */}
<Text style={styles.title}>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. */}
<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>
{purchaseOrder.po_number}
</Text>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Date: </Text>
{formatDateLong(generatedAtIso)}
</Text>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Order Ref: </Text>
{order.order_number}
</Text>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Status: </Text>
{purchaseOrder.status || "draft"}
</Text>
</View>
</View>
{/* Items table. Description = po_description (supplier-facing
copy), with description as a fallback when po_description
is blank. Matches what the operator sees on the process
page after editing the inline PO description. */}
<View style={styles.table}>
<View style={[styles.tableHeaderRow, { backgroundColor: accent }]}>
<Text style={[styles.tableHeaderCell, styles.colDescription]}>Description</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) => {
// Inside the items map — qty comes from `order_qty` (supplier-facing)
// when populated, otherwise falls back to `quantity` so we never print
// a zero for a line where the operator left order_qty blank.
const qty = Number(item.order_qty ?? item.quantity) || 0;
const cost = Number(item.cost_price) || 0;
const lineTotal = Math.round(qty * cost * 100) / 100;
// PO description is supplier-facing copy. If blank, fall
// back to the internal description so we never print an
// empty cell — that'd look like a bug to the supplier.
const desc = (item.po_description || item.description || "").trim();
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.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 — Subtotal + VAT (15%) + rule + Total. Mirrors the
quote's totals block so the two PDFs feel like siblings
when filed side-by-side. `Subtotal` is the VAT-ex sum
(stored on `purchase_orders.total_amount`); the printed
`Total` is `subtotal × 1.15` — the figure the office
uses for budget / VAT-return reconciliation. */}
<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.totalLine}>
<Text style={styles.totalLabel}>VAT (15%):</Text>
<Text style={styles.totalValue}>{formatZAR(vat)}</Text>
</View>
<View style={styles.totalRule} />
<View style={styles.totalLine}>
<Text style={styles.totalFinalLabel}>Total:</Text>
<Text style={styles.totalFinalValue}>{formatZAR(totalInclVat)}</Text>
</View>
</View>
</Page>
</Document>
);
}
/**
* Styles — kept in lockstep with `quote-document.tsx` so the two
* PDFs feel like siblings when printed side-by-side. If you change
* a style here, mirror it in quote-document.tsx (and vice-versa).
*/
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" },
// Logo absolutely-positioned over the page — see the long comment
// on the same block in quote-document.tsx for the flexbox-Image
// bug we're working around.
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: 4 },
colQty: { flex: 1 },
colPrice: { flex: 1.5 },
colTotal: { flex: 1.5 },
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 },
});