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 };