import { Image, View } from "@react-pdf/renderer"; import { pinned } from "../engine/columns"; import { textLines } from "../engine/textplan"; import { roleToken, type RoleName, type TypeToken } from "../fonts/roles"; import type { RenderModel } from "../model/build"; import { useAvailableWidth } from "./AvailableWidth"; import { IdText } from "./IdText"; import { Row } from "./Row"; import { SafeTxt } from "./SafeTxt"; import { Txt } from "./Txt"; /* * Regulated data (GSTIN, place of supply, HSN/SAC, tax rows, amount in words, signatory, reverse * charge) is printed ONLY through these components, so one contract test can prove that every * template carries what a document type requires (model/slots.ts) and nothing it forbids. Each slot * stamps an id (`slot-*`) on its output so the test finds it in the layout tree. Templates must not * read these model fields themselves (slots.lint.test.ts). */ export const SLOT_ID = { gstin: "slot-gstin", pos: "slot-pos", taxRows: "slot-tax-rows", amountInWords: "slot-words", signatory: "slot-signatory", reverseCharge: "slot-reverse-charge", } as const; type Role = RoleName | TypeToken; const INK_SUB = "#393939"; const FINE = "#6f6f6f"; const SIGNATURE_RULE = "#8d8d8d"; function Gstin({ model, party = "supplier", role = "body", color, align }: { model: RenderModel; party?: "supplier" | "client"; role?: Role; color?: string; align?: "left" | "right" }) { const value = party === "supplier" ? (model.slots.gstin ? model.supplier.gstin : null) : model.client.gstin; if (!value) return null; return ( {`GSTIN: ${value}`} ); } function PlaceOfSupply({ model, role = "body", color, align }: { model: RenderModel; role?: Role; color?: string; align?: "left" | "right" }) { if (!model.client.posLabel) return null; return ( {`Place of Supply: ${model.client.posLabel}`} ); } /** The HSN/SAC column: header and one value per item row, or null when no item carries a code. */ function hsnSacColumn(model: RenderModel): { header: string; values: string[] } | null { if (!model.slots.hsn) return null; return { header: "HSN/SAC", values: model.items.map((i) => i.hsnSac ?? "—") }; } export interface TaxRowsProps { model: RenderModel; role?: Role; amountWidth: number; rowStyle?: Record; /** Label text; default "CGST (9%)". */ format?: (label: string, rateText: string | undefined) => string; labelAlign?: "left" | "right"; /** Space between the label and the amount column. */ labelGap?: number; color?: string; } /** CGST/SGST (UTGST) or IGST rows, as label (rate) on the left and the amount on the right. */ function TaxRows({ model, role = "body", amountWidth, rowStyle, format, labelAlign, labelGap = 0, color }: TaxRowsProps) { const w = useAvailableWidth(); if (model.totals.taxRows.length === 0) return null; const text = format ?? ((label: string, rate: string | undefined) => (rate ? `${label} (${rate})` : label)); return ( {model.totals.taxRows.map((r, i) => ( {text(r.label, r.rateText)} {r.amountText} ))} ); } export interface AmountInWordsProps { model: RenderModel; role?: Role; style?: Record; /** Without it the label and the words are one italic paragraph (Classic); with it the label is its own line in this role. */ labelRole?: Role; color?: string; } function AmountInWords({ model, role = "body", style, labelRole, color }: AmountInWordsProps) { if (!model.amountInWords) return null; if (labelRole) { return ( Amount in words: {model.amountInWords} ); } return ( {`Amount in words: ${model.amountInWords}`} ); } function ReverseCharge({ model, role = "body", color, style }: { model: RenderModel; role?: Role; color?: string; style?: Record }) { if (!model.reverseCharge) return null; return ( {model.reverseCharge} ); } /** Predicted heights of the slots that sit in the totals block, for its presence value. */ export const slotHeights = { /** Lines the GSTIN slot prints for a party (0 or 1; an identifier that cannot fit may wrap further, which only makes the page longer). */ gstinLines: (model: RenderModel, party: "supplier" | "client"): number => (party === "supplier" ? (model.slots.gstin && model.supplier.gstin) : model.client.gstin) ? 1 : 0, /** Lines the place-of-supply slot prints at `width`. */ placeOfSupplyLines: (model: RenderModel, role: Role, width: number): number => model.client.posLabel ? textLines(role, `Place of Supply: ${model.client.posLabel}`, width) : 0, /** Whether the item has an HSN/SAC value to print (a code, not the "—" placeholder). */ hasHsn: (value: string): boolean => value !== "—", /** Number of tax rows (each is one body line plus the template's row padding). */ taxRowCount: (model: RenderModel): number => model.totals.taxRows.length, /** Height of AmountInWords at `width`; pass the same roles the layout prints it in. */ amountInWords: (model: RenderModel, width: number, roles: { role?: Role; labelRole?: Role } = {}): number => { if (!model.amountInWords) return 0; const role = roles.role ?? "body"; const leading = (r: Role) => roleToken(r).leading as number; if (roles.labelRole) { return textLines(roles.labelRole, "Amount in words:", width) * leading(roles.labelRole) + 2 + textLines(role, model.amountInWords, width) * leading(role); } return textLines(role, `Amount in words: ${model.amountInWords}`, width, { italic: true }) * leading(role); }, reverseCharge: (model: RenderModel, width: number, role: Role = "small"): number => model.reverseCharge ? textLines(role, model.reverseCharge, width) * (roleToken(role).leading as number) : 0, }; export const SIGNATURE_IMAGE_H = 58; const FINE_LINE = "Computer-generated invoice; no signature is required."; export interface SignatoryOptions { roles?: { name: Role; designation: Role; fine: Role }; /** Text and picture alignment; the default is right. */ align?: "left" | "right"; /** Rule between the picture and the name. Default: a 0.8 pt grey one (Classic); a template passes its ink. */ rule?: { width: number; color: string } | null; color?: string; imageHeight?: number; } const SIGN_ROLES = { name: "bodyStrong", designation: "body", fine: "fine" } as const satisfies NonNullable; const DEFAULT_RULE = { width: 0.8, color: SIGNATURE_RULE }; /** Height of the signatory block at `width`, for the closing block's presence value. */ export function signatoryHeight(model: RenderModel, width: number, opts: SignatoryOptions = {}): number { const leading = (r: Role) => roleToken(r).leading as number; const roles = opts.roles ?? SIGN_ROLES; const rule = opts.rule === undefined ? DEFAULT_RULE : opts.rule; const { name, designation, dataUri } = model.signature; let h = (dataUri ? (opts.imageHeight ?? SIGNATURE_IMAGE_H) : 0) + 4 + (rule?.width ?? 0) + 3; h += textLines(roles.name, name, width) * leading(roles.name); if (designation) h += textLines(roles.designation, designation, width) * leading(roles.designation); if (!dataUri) h += 6 + textLines(roles.fine, FINE_LINE, width) * leading(roles.fine); return h; } /** * Signature image (if any), a rule, the signatory's name and designation. Without an image the * "computer-generated" line stands in for the signature. */ function Signatory({ model, width, ...opts }: { model: RenderModel; width?: number } & SignatoryOptions) { const avail = useAvailableWidth(); const w = width ?? avail; const { name, designation, dataUri } = model.signature; const roles = opts.roles ?? SIGN_ROLES; const align = opts.align ?? "right"; const rule = opts.rule === undefined ? DEFAULT_RULE : opts.rule; const custom = opts.color !== undefined; return ( {dataUri ? ( // Pinned box (an Image may be pinned); the picture sits at its edge and bottom, just above the rule. ) : null} {name} {designation ? ( {designation} ) : null} {!dataUri ? ( {FINE_LINE} ) : null} ); } export const Slot = { Gstin, PlaceOfSupply, HsnSac: hsnSacColumn, TaxRows, AmountInWords, Signatory, ReverseCharge };