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.
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user