Files
Voiced/src/pdf/blocks/slots.tsx
T
xavierk fa4d711dc0 Rebuild the Classic template on the engine with A4/Letter and a template harness
Classic now renders through buildRenderModel, computeFrame, the template
and renderCore, with S.No and HSN/SAC columns, GST slots only through
checked Slot components, taxable value, reverse-charge line, cancelled tag,
continuation band and Page n of N. Letter absorbs its height deficit in
elastic gaps. A harness runs 15 fixtures on A4 and Letter plus an item
sweep and asserts zero audit errors, repeated headers, no split blocks,
rupee extraction and deterministic output. The legacy InvoiceDocument is
removed.
2026-10-04 05:31:14 +05:30

158 lines
6.5 KiB
TypeScript

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 }: { model: RenderModel; party?: "supplier" | "client"; role?: Role; color?: string }) {
const value = party === "supplier" ? (model.slots.gstin ? model.supplier.gstin : null) : model.client.gstin;
if (!value) return null;
return (
<View id={`${SLOT_ID.gstin}-${party}`}>
<IdText role={role} color={color}>{`GSTIN: ${value}`}</IdText>
</View>
);
}
function PlaceOfSupply({ model, role = "body", color }: { model: RenderModel; role?: Role; color?: string }) {
if (!model.client.posLabel) return null;
return (
<SafeTxt id={SLOT_ID.pos} role={role} color={color}>
{`Place of Supply: ${model.client.posLabel}`}
</SafeTxt>
);
}
/** 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<string, unknown>;
}
/** 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 }: TaxRowsProps) {
const w = useAvailableWidth();
if (model.totals.taxRows.length === 0) return null;
return (
<View id={SLOT_ID.taxRows}>
{model.totals.taxRows.map((r, i) => (
<Row key={i} widths={[{ fill: true }, { fixed: amountWidth }]} width={w} style={rowStyle}>
<Txt role={role} width={w - amountWidth}>{r.rateText ? `${r.label} (${r.rateText})` : r.label}</Txt>
<Txt role={role} width={amountWidth} align="right">{r.amountText}</Txt>
</Row>
))}
</View>
);
}
function AmountInWords({ model, role = "body", style }: { model: RenderModel; role?: Role; style?: Record<string, unknown> }) {
if (!model.amountInWords) return null;
return (
<SafeTxt id={SLOT_ID.amountInWords} role={role} italic style={style}>
{`Amount in words: ${model.amountInWords}`}
</SafeTxt>
);
}
function ReverseCharge({ model, role = "body", color, style }: { model: RenderModel; role?: Role; color?: string; style?: Record<string, unknown> }) {
if (!model.reverseCharge) return null;
return (
<SafeTxt id={SLOT_ID.reverseCharge} role={role} color={color} style={style}>
{model.reverseCharge}
</SafeTxt>
);
}
/** Predicted heights of the slots that sit in the totals block, for its presence value. */
export const slotHeights = {
/** Number of tax rows (each is one body line plus the template's row padding). */
taxRowCount: (model: RenderModel): number => model.totals.taxRows.length,
amountInWords: (model: RenderModel, width: number): number =>
model.amountInWords ? textLines("body", `Amount in words: ${model.amountInWords}`, width, { italic: true }) * (roleToken("body").leading as number) : 0,
reverseCharge: (model: RenderModel, width: number): number =>
model.reverseCharge ? textLines("small", model.reverseCharge, width) * (roleToken("small").leading as number) : 0,
};
export const SIGNATURE_IMAGE_H = 58;
const FINE_LINE = "Computer-generated invoice; no signature is required.";
/** Height of the signatory block at `width`, for the closing block's presence value. */
export function signatoryHeight(model: RenderModel, width: number): number {
const leading = (r: Role) => roleToken(r).leading as number;
const { name, designation, dataUri } = model.signature;
let h = (dataUri ? SIGNATURE_IMAGE_H : 0) + 4 + 0.8 + 3;
h += textLines("bodyStrong", name, width) * leading("bodyStrong");
if (designation) h += textLines("body", designation, width) * leading("body");
if (!dataUri) h += 6 + textLines("fine", FINE_LINE, width) * leading("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 }: { model: RenderModel; width?: number }) {
const avail = useAvailableWidth();
const w = width ?? avail;
const { name, designation, dataUri } = model.signature;
return (
<View id={SLOT_ID.signatory} style={pinned(w) as never}>
{dataUri ? (
// Pinned box (an Image may be pinned); the picture sits at its right and bottom edge, just above the rule.
<Image
src={dataUri}
style={{ ...pinned(w), height: SIGNATURE_IMAGE_H, objectFit: "contain", objectPositionX: "100%", objectPositionY: "100%" } as never}
/>
) : null}
<View style={{ marginTop: 4, paddingTop: 3, borderTopWidth: 0.8, borderTopColor: SIGNATURE_RULE, ...pinned(w) } as never}>
<SafeTxt role="bodyStrong" width={w} align="right">{name}</SafeTxt>
{designation ? (
<SafeTxt role="body" width={w} align="right" color={INK_SUB}>{designation}</SafeTxt>
) : null}
</View>
{!dataUri ? (
<Txt role="fine" color={FINE} width={w} align="right" style={{ marginTop: 6 }}>
{FINE_LINE}
</Txt>
) : null}
</View>
);
}
export const Slot = { Gstin, PlaceOfSupply, HsnSac: hsnSacColumn, TaxRows, AmountInWords, Signatory, ReverseCharge };