WFDASH-WEBAPP/src/lib/pdf/quote-document.tsx
Dave 6796dbea06 feat(pdf): add noQuote prop to QuoteDocument
Renders 'Not quoted' in item price cells, '—' in totals, and
changes the quote_notes callout header to 'Why we cannot quote'
when the flag is set. Visual identity stays the same (still a
QUOTATION document on letterhead).
2026-08-26 23:40:47 +02:00

484 lines
No EOL
18 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 { 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 `<QuoteDocument>`. 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 (
<Document
title={`Quotation ${quote.quote_number}`}
author={letterhead.display_name}
subject={`Quotation for ${customer.customer}`}
>
<Page size="A4" style={styles.page}>
{/* 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. */}
<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>
{/* 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 ? (
<View style={styles.logoAbsolute}>
<Image src={logoDataUri} style={styles.logo} />
</View>
) : null}
{/* QUOTATION heading */}
<Text style={styles.title}>QUOTATION</Text>
{/* TO + Quote meta two-column row */}
<View style={styles.metaRow}>
<View style={styles.metaCol}>
<Text style={styles.bold}>TO:</Text>
<Text style={styles.body}>{customer.customer}</Text>
<Text style={styles.body}>{customer.email}</Text>
<Text style={styles.body}>{customer.number}</Text>
</View>
<View style={[styles.metaCol, styles.metaColRight]}>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Quote No: </Text>
{quote.quote_number}
</Text>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Date: </Text>
{formatDateLong(effectiveQuoteDate(quote))}
</Text>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Valid until: </Text>
{formatDateLong(addDays(effectiveQuoteDate(quote), effectiveValidityDays(quote)))}
</Text>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Reference: </Text>
{quote.quote_reference || ""}
</Text>
</View>
</View>
{/* Items table */}
<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 Price</Text>
<Text style={[styles.tableHeaderCell, styles.colTotal, styles.alignRight]}>Total</Text>
</View>
{items.map((item, idx) => (
<View
key={item.id || idx}
style={[styles.tableRow, ...(idx % 2 === 1 ? [styles.tableRowAlt] : [])]}
>
<Text style={[styles.tableCell, styles.colDescription]}>{item.description}</Text>
<Text style={[styles.tableCell, styles.colQty, styles.alignRight]}>
{item.quantity}
</Text>
<Text style={[styles.tableCell, styles.colPrice, styles.alignRight]}>
{noQuote ? "Not quoted" : formatZAR(item.selling_price)}
</Text>
<Text style={[styles.tableCell, styles.colTotal, styles.alignRight]}>
{noQuote ? "Not quoted" : formatZAR(item.quantity * item.selling_price)}
</Text>
</View>
))}
</View>
{/* 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. */}
<View style={styles.totalsRow}>
{quoteNotes && quoteNotes.trim() ? (
<View
style={[
styles.quoteNote,
{
borderColor: accent,
backgroundColor: accentTint(accent),
},
]}
>
<Text style={[styles.quoteNoteLabel, { color: accent }]}>
{noQuote ? "Why we cannot quote" : "Note"}
</Text>
<Text style={styles.quoteNoteText}>{quoteNotes}</Text>
</View>
) : null}
<View style={styles.totals}>
<View style={styles.totalLine}>
<Text style={styles.totalLabel}>Subtotal:</Text>
<Text style={styles.totalValue}>{noQuote ? "—" : formatZAR(subtotal)}</Text>
</View>
<View style={styles.totalLine}>
<Text style={styles.totalLabel}>VAT (15%):</Text>
<Text style={styles.totalValue}>{noQuote ? "—" : formatZAR(vat)}</Text>
</View>
<View style={styles.totalRule} />
<View style={styles.totalLine}>
<Text style={styles.totalFinalLabel}>Total:</Text>
<Text style={styles.totalFinalValue}>{noQuote ? "—" : formatZAR(total)}</Text>
</View>
</View>
</View>
{/* 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 ? (
<View style={styles.partialNote}>
<Text style={styles.partialNoteText}>{partialNote}</Text>
</View>
) : null}
{/* Terms and Conditions */}
<Text style={styles.sectionTitle}>Terms and Conditions:</Text>
<Text style={styles.terms}>{stripTermsHeading(letterhead.terms)}</Text>
{/* Banking Details */}
<Text style={styles.sectionTitle}>Banking Details:</Text>
<Text style={styles.body}>{letterhead.bank_name}</Text>
<Text style={styles.body}>{letterhead.bank_account_number}</Text>
<Text style={styles.body}>Branch Code: {letterhead.bank_branch_code}</Text>
{/* Footer (from letterhead_settings.footer_text if set) */}
{letterhead.footer_text ? (
<Text style={styles.footerText}>{letterhead.footer_text}</Text>
) : null}
</Page>
</Document>
);
}
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,
},
});