import { Document, Page, View, Text, Image, StyleSheet } from "@react-pdf/renderer"; import type { Letterhead } from "@/lib/letterhead"; import type { Customer, Quote, QuoteItem } from "@/lib/types"; import { formatZAR, formatDateLong, addDays, quoteSubtotal, vatAmount, effectiveQuoteDate, effectiveValidityDays, } from "./format"; import { defaultAccentFor } from "@/lib/letterhead"; /** * Some PB `letterhead_settings.terms` values start with their own * "Terms and Conditions:" prefix (because the office typed the * full block including the heading). We always render our own bold * heading in the document, so strip the redundant prefix here to * avoid printing it twice. */ function stripTermsHeading(raw: string): string { if (!raw) return ""; // Matches "Terms and Conditions:" / "Terms and Conditions :" / "Terms & Conditions:" etc., // case-insensitive, with optional surrounding whitespace and the first newline after. return raw.replace( /^\s*terms\s*(?:&|and)\s*conditions\s*:\s*\r?\n?/i, "" ); } /** * Compute a pale tint of the accent colour for the operator-note * background. React-PDF doesn't support `rgba(...)` / opacity, so * we pre-mix the accent with white at a fixed ratio (12 % accent, * 88 % white) to get a usable background swatch. Rough on saturated * colours but fine for the muted brand underlines we use as * accents (CNL green #738224, ISherwood green #7ed957). * * Falls back to the existing light-blue swatch if the accent is * malformed (defensive — the letterhead row is optional in PB). */ function accentTint(accent: string): string { const m = /^#?([0-9a-fA-F]{6})$/.exec(accent); if (!m) return "#EEF3FF"; const n = parseInt(m[1] as string, 16); const r = (n >> 16) & 0xff; const g = (n >> 8) & 0xff; const b = n & 0xff; const mix = (c: number) => Math.round(c * 0.12 + 255 * 0.88); const hex = (c: number) => c.toString(16).padStart(2, "0"); return `#${hex(mix(r))}${hex(mix(g))}${hex(mix(b))}`; } /** * Props for ``. Everything the PDF needs is pre-resolved * by the API route — the document itself never touches PB or the network. * That keeps this component a pure, easy-to-test function of its inputs. */ export interface QuoteDocumentProps { quote: Quote; items: QuoteItem[]; customer: Customer; letterhead: Letterhead; /** Logo as a `data:image/svg+xml;charset=utf-8,...` URI. We use SVG * instead of the original raster (PNG/JPEG) logos because * @react-pdf/renderer v4.5.1 has a confirmed byte-corruption bug * that replaces every byte ≥ 0x80 in raster image data with `0xFD`, * turning valid JPEGs (`0xFF 0xD8` SOI) into garbage. SVG is text- * based, so it sidesteps the bug entirely — the data URI is a plain * UTF-8 string and goes through @react-pdf's SVG renderer (which * parses XML and embeds vector paths), not the broken raster path. * * If we ever need a non-SVG logo again, the upstream fixes are: * - Upgrade @react-pdf past the byte-corruption regression * - Post-process with PDFKit's `doc.image()` (bypasses the bug) * - Rasterize the SVG→PNG with a server-side library and embed * the PNG via PDFKit the same way */ logoDataUri: string | null; /** * Optional note shown above the totals when this is a partial * quote (e.g. "5 of 15 items — remaining items to follow in a * separate quote"). Renders as an amber-bordered callout so the * customer can see at a glance that more is coming without * exposing the internal "this is quote 1 of 3" split workflow. * * Empty / undefined = no callout (the standard full-quote * layout). The PDF route is responsible for setting this when * the quote's status is `partial_approved` or * `partial_approval_pending`. * * Added 2026-07-24 alongside the partial-approval flow. */ partialNote?: string | null; /** * Operator-facing free-text note (e.g. "Valid for 30 days", "Lead * time 5 working days"). Rendered in a blue callout below the * totals, above the partial-quote callout and the Terms and * Conditions. Empty / undefined / null = no callout. * * Stored on the quote at `quotes.quote_notes` (NOT `quotes.notes`, * which is the rejection audit log). Wired by the PDF route from * the freshly-loaded quote row. * * Added 2026-08-17. */ quoteNotes?: string | null; /** * When true, the quote is being rendered as a "decline / no-quote" * letter instead of a priced quotation. The PDF route bypasses the * normal price/processed/supplier filter when this flag is set, and * passes every active item through. The component itself decides * how to render the per-row cells (Task 8 will wire the * "Not quoted" text in the price/total columns). * * Default false = standard priced quotation. Added 2026-08-26 * alongside the no-quote / decline-letter feature. */ noQuote?: boolean; } /** * Quote PDF — A4 portrait, matches the layout of the existing sample PDF: * - top-left: company block (name + address + phone + email + VAT + reg) * - top-right: logo * - big "QUOTATION" heading * - two-column: TO: customer block (left), Quote No / Date / Valid until / Reference (right) * - items table with accent-coloured header row * - right-aligned totals (Subtotal, VAT, Total — rule above Total) * - Terms and Conditions block (multi-line) * - Banking Details block (multi-line) * * Render with `@react-pdf/renderer`'s `pdf()` in the API route — see * `src/lib/pdf/render.ts`. */ export function QuoteDocument({ quote, items, customer, letterhead, logoDataUri, partialNote, quoteNotes, noQuote, }: QuoteDocumentProps) { const subtotal = quoteSubtotal(items); const vat = vatAmount(subtotal); const total = Math.round((subtotal + vat) * 100) / 100; const accent = letterhead.accent_color || defaultAccentFor( (letterhead.display_name?.toUpperCase().includes("CNL") ? "CNL" : "ISHERWOOD") ); return ( {/* Header — company info on the left, logo floating on the right. NOTE: @react-pdf's flexbox layout doesn't reliably position Images inside `flexDirection: row` (they get placed at the bottom of the layout container, off-page). The logo is therefore absolutely-positioned over the company info rather than flowing beside it — `position: 'absolute'` with explicit top/right puts the logo exactly where we want it, no flexbox-with-image-in-row trap to fall into. */} {letterhead.display_name} (Pty) Ltd {letterhead.address} {letterhead.phone} {letterhead.email} VAT No: {letterhead.vat_number} {letterhead.registration_number ? ( Business ID No: {letterhead.registration_number} ) : null} {/* Logo — SVG data URI, absolutely-positioned over the page so we control exact placement (top + right from page corner) without fighting @react-pdf's flexbox-Image quirks. See `logoDataUri` JSDoc on QuoteDocumentProps for the upstream bug we're working around. */} {logoDataUri ? ( ) : null} {/* QUOTATION heading */} QUOTATION {/* TO + Quote meta two-column row */} TO: {customer.customer} {customer.email} {customer.number} Quote No: {quote.quote_number} Date: {formatDateLong(effectiveQuoteDate(quote))} Valid until: {formatDateLong(addDays(effectiveQuoteDate(quote), effectiveValidityDays(quote)))} Reference: {quote.quote_reference || ""} {/* Items table */} Description Qty Unit Price Total {items.map((item, idx) => ( {item.description} {item.quantity} {noQuote ? "Not quoted" : formatZAR(item.selling_price)} {noQuote ? "Not quoted" : formatZAR(item.quantity * item.selling_price)} ))} {/* Totals row — note (left, in the accent palette) sits next to the totals block (right). Rows are siblings in a flex container so the note and the totals share the same horizontal band — the operator's note is part of the "summary" footer rather than a separate callout below. The note is omitted entirely when `quoteNotes` is empty / whitespace, so the totals block just left-shifts to fill the row. */} {quoteNotes && quoteNotes.trim() ? ( {noQuote ? "Why we cannot quote" : "Note"} {quoteNotes} ) : null} Subtotal: {noQuote ? "—" : formatZAR(subtotal)} VAT (15%): {noQuote ? "—" : formatZAR(vat)} Total: {noQuote ? "—" : formatZAR(total)} {/* Partial-quote callout — only when partialNote is set. Amber-bordered, sits below the totals and above the T&Cs so the customer sees it without scrolling. The text comes from the caller; the route builds it from the quote's item counts (e.g. "5 of 15 items — remaining items to follow in a separate quote"). */} {partialNote ? ( {partialNote} ) : null} {/* Terms and Conditions */} Terms and Conditions: {stripTermsHeading(letterhead.terms)} {/* Banking Details */} Banking Details: {letterhead.bank_name} {letterhead.bank_account_number} Branch Code: {letterhead.bank_branch_code} {/* Footer (from letterhead_settings.footer_text if set) */} {letterhead.footer_text ? ( {letterhead.footer_text} ) : null} ); } 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 so it floats over the header without // pushing the QUOTATION heading down or fighting @react-pdf's // flexbox-Image placement bug. `top` is from the page top edge // (so `top: 40` lines up with the company name's top, since the // page itself has `paddingTop: 40`). `right` is from the page right // edge inward (so `right: 40` aligns the logo's right edge with the // right margin the rest of the text uses — flush with the // customer/quote meta block). Tweak these to taste. logoAbsolute: { position: "absolute", top: 40, right: 40, }, // Logo dimensions — big enough to read on a printed page without // shouting. CNL's SVG is 323×200 (aspect 1.615), ISherwood's is // 341×200 (1.705); 160×100 keeps both at their natural proportions // without distortion. `objectFit` is NOT supported in @react-pdf // so the box size IS the rendered size. 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" }, totalsRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", marginTop: 10, gap: 12, }, totals: { minWidth: 220, // Push the totals to the right edge of the totals row whether // or not the operator note is present. Without this, the totals // would shift to the left edge when the note is empty (because // it would be the only flex child of a `justify-content: // space-between` row). marginLeft: "auto", }, 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 }, sectionTitle: { fontFamily: "Helvetica-Bold", fontSize: 10, marginTop: 18, marginBottom: 6, }, terms: { fontSize: 10, lineHeight: 1.4, marginBottom: 8, }, footerText: { fontSize: 8, color: "#888", marginTop: 24, textAlign: "center", }, // Partial-quote callout (added 2026-07-24). Amber border + light // amber background, sits between the totals block and the // Terms and Conditions. The route is responsible for // constructing the text; we just style the box. partialNote: { marginTop: 16, padding: 10, borderWidth: 1, borderColor: "#F2AF58", // matches the orange accent in the brand palette backgroundColor: "#FFF6E5", borderRadius: 2, }, partialNoteText: { fontSize: 10, color: "#6B4A1E", lineHeight: 1.4, }, // Operator note (added 2026-08-17). Sits in the same horizontal // row as the totals block, to the left of it. Border + label // colour are bound to the accent inline; background gets a // computed pale tint of the accent. Text body uses #222 so it // stays readable on the soft tint. quoteNote: { flex: 1, padding: 10, borderWidth: 1, borderRadius: 2, }, quoteNoteLabel: { fontFamily: "Helvetica-Bold", fontSize: 10, marginBottom: 4, }, quoteNoteText: { fontSize: 10, color: "#222", lineHeight: 1.4, }, });