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).
484 lines
No EOL
18 KiB
TypeScript
484 lines
No EOL
18 KiB
TypeScript
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,
|
||
},
|
||
}); |