Linea (the vendor's original format.pdf design), Monogram and Serenity are rebuilt from the Canva PDFs as one parametric family on the engine, with the user's logo in the measured slot, a home for every GST field and A4/Letter support. Adds static Montserrat, Open Sans and Poppins fonts and a generated Ms Madi outline for Serenity's vertical word. Text-only logo placeholders (Monolith, Tangerine) use a doc-derived allowance box. A Carbon TemplatePicker with committed thumbnails opens from the page setup controls.
219 lines
9.8 KiB
TypeScript
219 lines
9.8 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, 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 (
|
|
<View id={`${SLOT_ID.gstin}-${party}`}>
|
|
<IdText role={role} color={color} {...(align ? { align, fill: true } : {})}>{`GSTIN: ${value}`}</IdText>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function PlaceOfSupply({ model, role = "body", color, align }: { model: RenderModel; role?: Role; color?: string; align?: "left" | "right" }) {
|
|
if (!model.client.posLabel) return null;
|
|
return (
|
|
<SafeTxt id={SLOT_ID.pos} role={role} color={color} align={align}>
|
|
{`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>;
|
|
/** 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 (
|
|
<View id={SLOT_ID.taxRows}>
|
|
{model.totals.taxRows.map((r, i) => (
|
|
<Row key={i} widths={[{ fill: true }, { fixed: amountWidth }]} gap={labelGap} width={w} style={rowStyle}>
|
|
<Txt role={role} color={color} align={labelAlign} width={w - amountWidth - labelGap}>{text(r.label, r.rateText)}</Txt>
|
|
<Txt role={role} color={color} width={amountWidth} align="right">{r.amountText}</Txt>
|
|
</Row>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export interface AmountInWordsProps {
|
|
model: RenderModel;
|
|
role?: Role;
|
|
style?: Record<string, unknown>;
|
|
/** 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 (
|
|
<View id={SLOT_ID.amountInWords} style={style as never}>
|
|
<SafeTxt role={labelRole} color={color} style={{ marginBottom: 2 }}>Amount in words:</SafeTxt>
|
|
<SafeTxt role={role} color={color}>{model.amountInWords}</SafeTxt>
|
|
</View>
|
|
);
|
|
}
|
|
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 = {
|
|
/** 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<SignatoryOptions["roles"]>;
|
|
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 (
|
|
<View id={SLOT_ID.signatory} style={pinned(w) as never}>
|
|
{dataUri ? (
|
|
// Pinned box (an Image may be pinned); the picture sits at its edge and bottom, just above the rule.
|
|
<Image
|
|
src={dataUri}
|
|
style={{ ...pinned(w), height: opts.imageHeight ?? SIGNATURE_IMAGE_H, objectFit: "contain", objectPositionX: align === "right" ? "100%" : "0%", objectPositionY: "100%" } as never}
|
|
/>
|
|
) : null}
|
|
<View
|
|
style={{ marginTop: 4, paddingTop: 3, ...(rule ? { borderTopWidth: rule.width, borderTopColor: rule.color } : {}), ...pinned(w) } as never}
|
|
>
|
|
<SafeTxt role={roles.name} width={w} align={align} color={opts.color}>{name}</SafeTxt>
|
|
{designation ? (
|
|
<SafeTxt role={roles.designation} width={w} align={align} color={custom ? opts.color : INK_SUB}>{designation}</SafeTxt>
|
|
) : null}
|
|
</View>
|
|
{!dataUri ? (
|
|
<Txt role={roles.fine} color={custom ? opts.color : FINE} width={w} align={align} style={{ marginTop: 6 }}>
|
|
{FINE_LINE}
|
|
</Txt>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export const Slot = { Gstin, PlaceOfSupply, HsnSac: hsnSacColumn, TaxRows, AmountInWords, Signatory, ReverseCharge };
|