337 lines
13 KiB
TypeScript
337 lines
13 KiB
TypeScript
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 },
|
||
});
|
||
|