Files
Voiced/src/pdf/templates/monolith/Layout.tsx
T
xavierk 5d377ce85e Fix Monolith and Cobalt Stripe closing-block squeeze; guard Monolith flow notes
When only the closing block moved to page 2, the page-1 deficit was shared across every gap, including the closing block's own gaps, which cannot help page 1. Page 1 then came out a few points short and the totals spilled over with the closing block. Monolith and Cobalt Stripe now squeeze only the page-1 gaps in that case, as Marble and Highlighter already do.

Monolith's flow notes block could start in the last few points of a page and be cut into zero-height lines. It now holds 70 pt of room under its label first, as Marble does.

Adds tests for both cases (they failed before the fix) and notes in the template specs. Fingerprint goldens and thumbnails are unchanged.
2026-10-04 16:46:22 +05:30

414 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";
/** 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 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 : (
<>
{/* 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 }}>
<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} />
</>
);
}