import { Image, View } from "@react-pdf/renderer"; import type { ReactNode } from "react"; import { pinned, type ColSpec } from "../../engine/columns"; import { CLOSING_ID, TOTALS_ID } from "../../engine/ids"; import { AvailableWidthProvider } from "../../blocks/AvailableWidth"; import { BottomSpacer } from "../../blocks/BottomSpacer"; import { FirstPageHeader } from "../../blocks/FirstPageHeader"; import { fitSize, FitText } from "../../blocks/FitText"; import { IdText } from "../../blocks/IdText"; import { TableRows } from "../../blocks/ItemsTable"; import { Row } from "../../blocks/Row"; import { SafeTxt } from "../../blocks/SafeTxt"; import { Slot } from "../../blocks/slots"; import { TableGuard } from "../../blocks/TableGuard"; import { Txt } from "../../blocks/Txt"; import { AccentRule } from "../../decor/AccentRule"; import type { TypeToken } from "../../fonts/roles"; import { META_ID, SUPPLIER_ID, TITLE_ID } from "../classic/ids"; import type { PlacedLogo, TemplateProps } from "../contract"; import { includesName } from "../logoName"; import { CANCELLED_INK, CANCELLED_SENTENCE } from "../neutral/plan"; import { LABEL_GAP, NOTES_FLOW_GAP, PARTY_GUTTER, payableLines, planMarble, REVERSE_GAP, signatureOptions, stepOf, taxLabel, type MarblePlan } from "./plan"; import { RULE, ZEBRA, type MarbleVariant } from "./tokens"; /** Room that must remain under the notes' label and first line, so the next lines are not squeezed into the page foot. */ const NOTES_HOLD = 70; /** Layout-tree ids of the blocks that are Marble's own (the harness ids are in classic/ids.ts). */ export const IDS_ID = "marble-ids"; export const PAYABLE_ID = "marble-payable"; export const BILL_ID = "marble-bill"; interface Ctx { model: TemplateProps["model"]; plan: MarblePlan; v: MarbleVariant; logo: PlacedLogo | null; } function Spacer({ h }: { h: number }) { return h > 0 ? : null; } function Rule({ ctx }: { ctx: Ctx }) { return ; } /** * One right-aligned line of tracked text. textkit adds the tracking after the last glyph too, so the box is * widened by one step and pulled back with a negative margin: the ink then ends on the edge. */ function RightText({ role, color, width, children }: { role: TypeToken; color: string; width: number; children: string }) { const step = stepOf(role); const fit = fitSize(role, children, width + step); if (!fit.fits) { return ( {children} ); } const sized = { ...role, size: fit.size }; const s = stepOf(sized); return ( {children} ); } // --- header ----------------------------------------------------------------------------------------------------- /** The vendor name (Lustria caps) or its logo; the design leaves this corner empty. */ function Brand({ ctx }: { ctx: Ctx }) { const { model, plan, v, logo } = ctx; const h = plan.head; const hd = v.head; return ( {logo ? ( ) : null} {h.showName ? ( {model.supplier.name} ) : null} ); } function Stack({ ctx }: { ctx: Ctx }) { const { model, plan, v } = ctx; const h = plan.head; const r = v.roles; return ( {model.docTitle} {h.lines.map((l, i) => ( {l.text} ))} ); } function HeaderRow({ ctx }: { ctx: Ctx }) { const { plan } = ctx; return ( ); } // --- parties ---------------------------------------------------------------------------------------------------- function ClientBlock({ ctx, width }: { ctx: Ctx; width: number }) { const { model, v } = ctx; const r = v.roles; const c = model.client; return ( Bill to: {c.name} {c.addressLines.map((line, i) => ( {line} ))} {c.poNumber ? {`PO: ${c.poNumber}`} : null} ); } function PayableBlock({ ctx, width }: { ctx: Ctx; width: number }) { const { model, v } = ctx; const r = v.roles; return ( Payable to: {payableLines(model).map((line, i) => ( {line} ))} ); } function IdsBlock({ ctx, width }: { ctx: Ctx; width: number }) { const { model, v } = ctx; const r = v.roles; const s = model.supplier; return ( Supplier: {s.pan ? ( {`PAN: ${s.pan}`} ) : null} ); } function Parties({ ctx }: { ctx: Ctx }) { const { plan, v } = ctx; const cols = plan.info.cols; const text = cols.map((w) => Math.max(40, w - PARTY_GUTTER)); const blocks: ReactNode[] = []; let k = 1; if (plan.info.hasBank) blocks.push(); if (plan.info.hasIds) blocks.push(); const specs: ColSpec[] = cols.map((w, i) => (i === cols.length - 1 ? { fill: true } : { fixed: w })); return ( {blocks} ); } // --- totals ----------------------------------------------------------------------------------------------------- function StackRow({ ctx, label, amount }: { ctx: Ctx; label: string; amount: string }) { const { plan, v } = ctx; const t = plan.tail; const r = v.roles; const labelW = t.boxW - t.valueW; return ( {label} {amount} ); } function TotalsBox({ ctx }: { ctx: Ctx }) { const { model, plan, v } = ctx; const t = plan.tail; return ( {t.stack.map((row, i) => ( ))} } /> {t.dueText} ); } function Tail({ ctx }: { ctx: Ctx }) { const { model, plan, v } = ctx; const t = plan.tail; const r = v.roles; const o = v.totals; return ( {t.reverseH > 0 ? 0 ? REVERSE_GAP : 0 }} /> : null} ); } // --- closing ---------------------------------------------------------------------------------------------------- /** Notes: a label held with the first line, every line unbreakable (a cut Text would leave a zero-line node). */ function Notes({ ctx, width }: { ctx: Ctx; width: number }) { const { plan, v } = ctx; const r = v.roles; const lines = plan.closing.notes; if (lines.length === 0) return null; return ( Notes: {lines[0]} {lines.slice(1).map((line, i) => ( {line} ))} ); } function Closing({ ctx }: { ctx: Ctx }) { const { model, plan, v } = ctx; const cl = v.closing; const c = plan.closing; const opts = signatureOptions(v); return ( <> {c.notesBeside ? : null} {model.cancelled ? ( {CANCELLED_SENTENCE} ) : null} ); } // --- the layout ------------------------------------------------------------------------------------------------- export function MarbleBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: MarbleVariant }) { const plan = planMarble(model, frame, v, logo, includesName({ model, prefs })); const ctx: Ctx = { model, plan, v, logo }; const r = v.roles; const t = v.table; const notesFlow: ReactNode = plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : ( <> {/* A block that starts with less than a line of room left is cut into zero-height lines: it starts on the next page instead. */} ); return ( <> (i % 2 === 0 ? { backgroundColor: ZEBRA } : {})} headCellStyle={(i) => (i === 0 ? { marginLeft: -(t.descInset - t.headDescInset) } : undefined)} headerTail={} headerTailH={t.headGap} tailH={plan.tailH} tail={} /> {notesFlow} ); }