Files
Voiced/src/pdf/templates/monolith/Layout.tsx
T
xavierk 385d7974cb Add the Monolith and Cobalt Stripe templates
Standalone layouts for Canva templates 3 and 8, with the user's logo sized to the
placeholder box, a CapRule decor primitive, optional TableRows rule/heading options,
and a separate 1..45-row sweep test file.
2026-10-04 11:25:19 +05:30

406 lines
14 KiB
TypeScript

import { Image, View } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import { pinned } 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 { 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 { 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 { atRate, LABEL_GAP, NOTES_FLOW_GAP, paymentRows, planMonolith, REVERSE_GAP, roleOf, signatureOptions, type MonolithPlan } from "./plan";
import type { MonolithVariant } from "./tokens";
/** Layout-tree ids of the blocks that are Monolith's own (the harness ids are in classic/ids.ts). */
export const BRAND_ID = "monolith-brand";
export const IDS_ID = "monolith-ids";
interface Ctx {
model: TemplateProps["model"];
plan: MonolithPlan;
v: MonolithVariant;
logo: PlacedLogo | null;
}
function Spacer({ h }: { h: number }) {
return h > 0 ? <View style={{ height: h }} /> : null;
}
function Rule({ ctx, width }: { ctx: Ctx; width?: number }) {
return <AccentRule width={width ?? ctx.plan.W} thickness={ctx.v.rule} color={ctx.v.ink} />;
}
// --- header -----------------------------------------------------------------------------------------------------
function Title({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const h = plan.head;
return (
<View style={{ ...pinned(h.titleW), marginLeft: v.titleIndent } as never}>
<FitText id={TITLE_ID} role={{ ...v.roles.title, size: h.titleSize }} color={v.ink} width={h.titleW}>
{model.docTitle}
</FitText>
</View>
);
}
/** The logo (right-anchored in the stack's column) or the vendor name; under it the date and number lines flow, so a tall logo never covers them. */
function Brand({ ctx }: { ctx: Ctx }) {
const { model, plan, v, logo } = ctx;
const h = plan.head;
const name = h.showName ? (
<View style={{ ...pinned(h.nameW), marginTop: h.nameOffset } as never}>
<AvailableWidthProvider width={h.nameW}>
<SafeTxt role={v.roles.brand} color={v.ink} align="right">
{model.supplier.name}
</SafeTxt>
</AvailableWidthProvider>
</View>
) : (
<View />
);
const body = logo ? (
<Row widths={[{ fill: true }, { fixed: logo.w + (h.showName ? v.stack.markGap : 0) }]} width={h.stackW}>
{name}
<Image src={logo.dataUri} style={{ ...pinned(logo.w), height: logo.h, marginLeft: h.showName ? v.stack.markGap : 0, objectFit: "contain" } as never} />
</Row>
) : (
name
);
return (
<View id={SUPPLIER_ID} style={{ ...pinned(h.stackW), paddingTop: h.zoneTop, paddingBottom: logo ? v.stack.logoGap : 0 } as never}>
<View id={BRAND_ID}>{body}</View>
</View>
);
}
function MetaStack({ ctx }: { ctx: Ctx }) {
const { plan, v } = ctx;
const h = plan.head;
return (
<View id={META_ID} style={pinned(h.stackW) as never}>
<AvailableWidthProvider width={h.stackW}>
{h.lines.map((l, i) => (
<IdText key={i} role={roleOf(v, l.kind)} color={l.kind === "cancelled" ? CANCELLED_INK : v.ink} align="right" fill width={h.stackW}>
{l.text}
</IdText>
))}
</AvailableWidthProvider>
</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 }}>
<Title ctx={ctx} />
<View style={pinned(plan.head.stackW) as never}>
<Brand ctx={ctx} />
<MetaStack ctx={ctx} />
</View>
</Row>
);
}
// --- info row ---------------------------------------------------------------------------------------------------
function ClientBlock({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const c = model.client;
const w = plan.info.clientW;
return (
<View style={pinned(w) as never}>
<AvailableWidthProvider width={w}>
<Txt role={r.heading} color={v.ink} width={w} style={{ marginBottom: v.headingGap }}>
Billed 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 IdsBlock({ ctx }: { ctx: Ctx }) {
const { model, v } = ctx;
const r = v.roles;
const s = model.supplier;
return (
<View id={IDS_ID} style={pinned(v.sideW) as never}>
<AvailableWidthProvider width={v.sideW}>
<Txt role={r.heading} color={v.ink} width={v.sideW} style={{ marginBottom: v.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>
);
}
// --- totals -----------------------------------------------------------------------------------------------------
function TotalRow({ ctx, label, amount, strong }: { ctx: Ctx; label: string; amount: string; strong?: boolean }) {
const { plan, v } = ctx;
const t = plan.tail;
const r = v.roles;
return (
<Row widths={[{ fixed: t.labelW }, { fixed: t.valueW }]} width={t.boxW} style={strong ? undefined : { marginBottom: v.totals.rowGap }}>
<Txt role={strong ? r.total : r.totalLabel} color={v.ink} width={t.labelW} align="center">
{label}
</Txt>
<Txt role={strong ? r.total : r.totalValue} color={v.ink} width={t.valueW - v.totals.valuePad} align="right">
{amount}
</Txt>
</Row>
);
}
function TotalsBox({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const t = plan.tail;
return (
<View style={{ ...pinned(t.boxW), paddingTop: v.totals.top } as never}>
<AvailableWidthProvider width={t.boxW}>
{t.stack.map((row, i) => (
<TotalRow 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={atRate} renderRow={(row) => <TotalRow ctx={ctx} label={row.label} amount={row.amount} />} />
<View style={{ marginTop: v.totals.ruleGap, marginBottom: v.totals.totalGap }}>
<Rule ctx={ctx} width={t.boxW} />
</View>
<TotalRow ctx={ctx} label="Total" amount={model.totals.totalText} strong />
</AvailableWidthProvider>
</View>
);
}
function Tail({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const t = plan.tail;
const r = v.roles;
return (
<View id={TOTALS_ID} wrap={false} style={pinned(plan.W) as never}>
<Rule ctx={ctx} />
<Row widths={[{ fill: true }, { fixed: t.boxW }]} gap={v.totals.leftGap} width={plan.W}>
<View style={{ ...pinned(t.leftW), paddingTop: t.leftH ? v.totals.top : 0 } as never}>
<Slot.AmountInWords model={model} role={r.small} labelRole={r.smallStrong} 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}
</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.smallStrong} color={v.ink} width={width} style={{ marginBottom: LABEL_GAP }}>
Notes / terms:
</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 Payment({ ctx }: { ctx: Ctx }) {
const { model, plan, v } = ctx;
const r = v.roles;
const cl = v.closing;
const w = plan.closing.leftW;
if (!model.bank) return <View />;
return (
<View style={pinned(w) as never}>
<AvailableWidthProvider width={w}>
<Txt role={r.heading} color={v.ink} width={w} style={{ marginBottom: cl.headingGap }}>
Payment information :
</Txt>
<SafeTxt role={r.body} color={v.ink}>
{model.bank.accountName}
</SafeTxt>
{paymentRows(model).map((x, i) => (
<Row key={i} widths={[{ fixed: cl.labelW }, { fixed: cl.colonW }, { fill: true }]} width={w}>
<Txt role={r.body} color={v.ink} width={cl.labelW}>
{x.label}
</Txt>
<Txt role={r.colon} color={v.ink} width={cl.colonW}>
:
</Txt>
<SafeTxt role={r.body} color={v.ink}>
{x.value}
</SafeTxt>
</Row>
))}
</AvailableWidthProvider>
</View>
);
}
function Vendor({ ctx }: { ctx: Ctx }) {
const { model, v } = ctx;
const r = v.roles;
const s = model.supplier;
return (
<View style={pinned(v.sideW) as never}>
<AvailableWidthProvider width={v.sideW}>
<SafeTxt role={r.vendor} color={v.ink} style={{ marginBottom: v.closing.headingGap }}>
{s.name}
</SafeTxt>
{s.addressLines.map((line, i) => (
<SafeTxt key={i} role={r.body} color={v.ink}>
{line}
</SafeTxt>
))}
{s.phone ? (
<IdText role={r.body} color={v.ink}>
{s.phone}
</IdText>
) : null}
{s.email ? (
<IdText role={r.body} color={v.ink}>
{s.email}
</IdText>
) : null}
</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}>
<Row widths={[{ fixed: c.leftW }, { fixed: v.sideW }]} gap={v.sideGap} width={plan.W}>
{c.notesBeside ? <Notes ctx={ctx} width={c.leftW} /> : <View />}
<Slot.Signatory model={model} width={v.sideW} {...opts} />
</Row>
<Spacer h={plan.gaps.closeBand} />
<Rule ctx={ctx} />
<Spacer h={cl.top} />
<Row widths={[{ fixed: c.leftW }, { fixed: v.sideW }]} gap={v.sideGap} width={plan.W}>
<Payment ctx={ctx} />
<Vendor ctx={ctx} />
</Row>
<Spacer h={plan.gaps.closeBottom} />
<Rule ctx={ctx} />
{model.cancelled ? (
<Txt role={v.roles.small} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10 }}>
{CANCELLED_SENTENCE}
</Txt>
) : null}
</AvailableWidthProvider>
</View>
</>
);
}
// --- the layout -------------------------------------------------------------------------------------------------
export function MonolithBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: MonolithVariant }) {
const plan = planMonolith(model, frame, v, logo, includesName({ model, prefs }));
const ctx: Ctx = { model, plan, v, logo };
const r = v.roles;
const notesFlow: ReactNode = plan.closing.notesBeside || plan.closing.notes.length === 0 ? null : (
<View style={{ marginTop: NOTES_FLOW_GAP }}>
<Notes ctx={ctx} width={plan.W} />
</View>
);
return (
<>
<FirstPageHeader pad={frame.pad}>
<HeaderRow ctx={ctx} />
<Spacer h={v.headRuleGap} />
<Rule ctx={ctx} />
<Spacer h={plan.gaps.infoTop} />
{plan.info.hasIds ? (
<Row widths={[{ fixed: plan.info.clientW }, { fixed: v.sideW }]} gap={v.sideGap} width={plan.W}>
<ClientBlock ctx={ctx} />
<IdsBlock ctx={ctx} />
</Row>
) : (
<ClientBlock 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={plan.headPadY}
border={0}
headBorder={v.heavyRule}
borderColor={v.ink}
headColor={v.ink}
headerStyle={{ borderTopWidth: v.heavyRule, borderTopColor: v.ink }}
tailH={plan.tailH}
tail={<Tail ctx={ctx} />}
/>
</View>
{notesFlow}
<Closing ctx={ctx} />
</>
);
}