Files
Voiced/src/pdf/templates/marble/Layout.tsx
T
soubarna 5b18db3a77 fix: eslint findings
Remove dead code and unused imports, drop useless assignments, and tune
rules in eslint.config.js (underscore-prefixed unused names, intentional BOM/NBSP,
React Compiler rules from eslint-plugin-react-hooks 7 as warnings).
2026-10-05 17:29:03 +05:30

393 lines
14 KiB
TypeScript

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 ? <View style={{ height: h }} /> : null;
}
function Rule({ ctx }: { ctx: Ctx }) {
return <AccentRule width={ctx.plan.W} thickness={ctx.v.rule} color={RULE} />;
}
/**
* 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 (
<IdText role={role} color={color} width={width} align="right" fill>
{children}
</IdText>
);
}
const sized = { ...role, size: fit.size };
const s = stepOf(sized);
return (
<Txt role={sized} color={color} width={width + s} align="right" style={{ marginRight: -s }}>
{children}
</Txt>
);
}
// --- 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 (
<View id={SUPPLIER_ID} style={{ ...pinned(h.leftW), marginLeft: v.inset.left } as never}>
{logo ? (
<View style={{ ...pinned(h.leftW), height: Math.max(hd.logoH, h.logoTop + logo.h) } as never}>
<Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginTop: h.logoTop, objectFit: "contain" } as never} />
</View>
) : null}
{h.showName ? (
<View style={{ ...pinned(h.leftW), marginTop: logo ? hd.nameGap : hd.nameTop } as never}>
<AvailableWidthProvider width={h.leftW}>
<SafeTxt role={v.roles.brand} color={v.ink}>
{model.supplier.name}
</SafeTxt>
</AvailableWidthProvider>
</View>
) : null}
</View>
);
}
function Stack({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const h = plan.head;
const r = v.roles;
return (
<View style={{ ...pinned(h.stackW), paddingTop: v.head.titleTop } as never}>
<FitText id={TITLE_ID} role={{ ...r.title, size: h.titleSize }} color={v.ink} width={h.stackW + stepOf({ ...r.title, size: h.titleSize })} align="right" style={{ marginRight: -stepOf({ ...r.title, size: h.titleSize }) }}>
{model.docTitle}
</FitText>
<View id={META_ID} style={{ ...pinned(h.stackW), marginTop: v.head.titleGap } as never}>
<AvailableWidthProvider width={h.stackW}>
{h.lines.map((l, i) => (
<RightText key={i} role={r.meta} color={l.kind === "cancelled" ? CANCELLED_INK : v.ink} width={h.stackW}>
{l.text}
</RightText>
))}
</AvailableWidthProvider>
</View>
</View>
);
}
function HeaderRow({ ctx }: { ctx: Ctx }) {
const { plan } = ctx;
return (
<Row widths={[{ fill: true }, { fixed: plan.head.stackW }]} width={plan.W} style={{ minHeight: plan.head.h }}>
<Brand ctx={ctx} />
<Stack ctx={ctx} />
</Row>
);
}
// --- parties ----------------------------------------------------------------------------------------------------
function ClientBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
const r = v.roles;
const c = model.client;
return (
<View id={BILL_ID} style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
<Txt role={r.heading} color={v.ink} width={width} style={{ marginBottom: v.parties.headingGap }}>
Bill to:
</Txt>
<SafeTxt role={r.body} color={v.ink}>
{c.name}
</SafeTxt>
{c.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.body} color={v.ink}>
{line}
</SafeTxt>
))}
<Slot.Gstin model={model} party="client" role={r.body} color={v.ink} />
<Slot.PlaceOfSupply model={model} role={r.body} color={v.ink} />
{c.poNumber ? <SafeTxt role={r.body} color={v.ink}>{`PO: ${c.poNumber}`}</SafeTxt> : null}
</AvailableWidthProvider>
</View>
);
}
function PayableBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
const r = v.roles;
return (
<View id={PAYABLE_ID} style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
<Txt role={r.heading} color={v.ink} width={width} style={{ marginBottom: v.parties.headingGap }}>
Payable to:
</Txt>
{payableLines(model).map((line, i) => (
<SafeTxt key={i} role={r.body} color={v.ink}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
);
}
function IdsBlock({ ctx, width }: { ctx: Ctx; width: number }) {
const { model, v } = ctx;
const r = v.roles;
const s = model.supplier;
return (
<View id={IDS_ID} style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
<Txt role={r.heading} color={v.ink} width={width} style={{ marginBottom: v.parties.headingGap }}>
Supplier:
</Txt>
<Slot.Gstin model={model} party="supplier" role={r.body} color={v.ink} />
{s.pan ? (
<IdText role={r.body} color={v.ink}>
{`PAN: ${s.pan}`}
</IdText>
) : null}
</AvailableWidthProvider>
</View>
);
}
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[] = [<ClientBlock key="a" ctx={ctx} width={text[0]} />];
let k = 1;
if (plan.info.hasBank) blocks.push(<PayableBlock key="b" ctx={ctx} width={text[k++]} />);
if (plan.info.hasIds) blocks.push(<IdsBlock key="c" ctx={ctx} width={text[k]} />);
const specs: ColSpec[] = cols.map((w, i) => (i === cols.length - 1 ? { fill: true } : { fixed: w }));
return (
<View style={{ ...pinned(plan.W), paddingLeft: v.inset.left } as never}>
<Row widths={specs} width={plan.W - v.inset.left}>
{blocks}
</Row>
</View>
);
}
// --- 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 (
<Row widths={[{ fixed: labelW }, { fixed: t.valueW }]} width={t.boxW} style={v.totals.rowGap ? { marginBottom: v.totals.rowGap } : undefined}>
<Txt role={r.totalLabel} color={v.ink} width={labelW}>
{label}
</Txt>
<RightText role={r.totalValue} color={v.ink} width={t.valueW}>
{amount}
</RightText>
</Row>
);
}
function TotalsBox({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const t = plan.tail;
return (
<View style={pinned(t.boxW) as never}>
<AvailableWidthProvider width={t.boxW}>
{t.stack.map((row, i) => (
<StackRow key={i} ctx={ctx} label={row.label} amount={row.amount} />
))}
<Slot.TaxRows model={model} role={v.roles.totalLabel} color={v.ink} amountWidth={t.valueW} format={taxLabel} renderRow={(row) => <StackRow ctx={ctx} label={row.label} amount={row.amount} />} />
<View style={{ marginTop: v.totals.dueGap, ...pinned(t.boxW) } as never}>
<RightText role={{ ...v.roles.due, size: t.dueSize }} color={v.ink} width={t.boxW}>
{t.dueText}
</RightText>
</View>
</AvailableWidthProvider>
</View>
);
}
function Tail({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const t = plan.tail;
const r = v.roles;
const o = v.totals;
return (
<View id={TOTALS_ID} wrap={false} style={pinned(plan.W) as never}>
<Spacer h={plan.gaps.tail} />
<Row widths={[{ fill: true }, { fixed: t.boxW }]} gap={o.leftGap} width={plan.W - o.rightInset}>
<View style={{ ...pinned(t.leftW), marginLeft: v.inset.left } as never}>
<AvailableWidthProvider width={t.leftW}>
<Slot.AmountInWords model={model} role={r.small} labelRole={r.smallLabel} color={v.ink} />
{t.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.small} color={v.ink} style={{ marginTop: t.wordsH > 0 ? REVERSE_GAP : 0 }} /> : null}
</AvailableWidthProvider>
</View>
<TotalsBox ctx={ctx} />
</Row>
</View>
);
}
// --- 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 (
<View style={pinned(width) as never}>
<AvailableWidthProvider width={width}>
<View wrap={false}>
<Txt role={r.smallLabel} color={v.ink} width={width} style={{ marginBottom: LABEL_GAP }}>
Notes:
</Txt>
<SafeTxt role={r.small} color={v.ink}>
{lines[0]}
</SafeTxt>
</View>
{lines.slice(1).map((line, i) => (
<SafeTxt key={i} role={r.small} color={v.ink} wrap={false}>
{line}
</SafeTxt>
))}
</AvailableWidthProvider>
</View>
);
}
function Closing({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const cl = v.closing;
const c = plan.closing;
const opts = signatureOptions(v);
return (
<>
<BottomSpacer closingH={plan.closingH} />
<View id={CLOSING_ID} wrap={false} style={pinned(plan.W) as never}>
<AvailableWidthProvider width={plan.W}>
<Spacer h={plan.gaps.close} />
<Row widths={[{ fill: true }, { fixed: cl.signW }]} gap={cl.gap} width={plan.W - v.totals.rightInset}>
<View style={{ ...pinned(c.notesW), marginLeft: v.inset.left } as never}>{c.notesBeside ? <Notes ctx={ctx} width={c.notesW} /> : null}</View>
<Slot.Signatory model={model} width={cl.signW} {...opts} />
</Row>
{model.cancelled ? (
<Txt role={v.roles.small} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10, marginLeft: v.inset.left }}>
{CANCELLED_SENTENCE}
</Txt>
) : null}
</AvailableWidthProvider>
</View>
</>
);
}
// --- 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. */}
<TableGuard {...{ minPresenceAhead: NOTES_FLOW_GAP + NOTES_HOLD }} />
<View style={{ marginTop: NOTES_FLOW_GAP, marginLeft: v.inset.left }}>
<Notes ctx={ctx} width={plan.W - v.inset.left - v.totals.rightInset} />
</View>
</>
);
return (
<>
<FirstPageHeader pad={frame.pad}>
<HeaderRow ctx={ctx} />
<Spacer h={plan.gaps.headRule} />
<Rule ctx={ctx} />
<Spacer h={plan.gaps.infoTop} />
<Parties ctx={ctx} />
<Spacer h={plan.gaps.infoBottom} />
<Rule ctx={ctx} />
</FirstPageHeader>
<Spacer h={plan.gaps.band} />
<TableGuard {...{ minPresenceAhead: plan.guardH }} />
<View style={pinned(plan.W) as never}>
<TableRows
columns={plan.columns}
gap={0}
width={plan.W}
header={plan.headerCells}
rows={plan.rows}
roles={{ head: r.tableHead, body: r.tableBody }}
align={plan.align}
padX={0}
padLeft={plan.padLeft}
padRight={plan.padRight}
padY={plan.padY}
headPadY={0}
border={0}
headBorder={0}
headColor={v.ink}
rowStyle={(i) => (i % 2 === 0 ? { backgroundColor: ZEBRA } : {})}
headCellStyle={(i) => (i === 0 ? { marginLeft: -(t.descInset - t.headDescInset) } : undefined)}
headerTail={<Spacer h={t.headGap} />}
headerTailH={t.headGap}
tailH={plan.tailH}
tail={<Tail ctx={ctx} />}
/>
</View>
{notesFlow}
<Closing ctx={ctx} />
</>
);
}