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.
406 lines
14 KiB
TypeScript
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} />
|
|
</>
|
|
);
|
|
}
|