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 ? : null; } function Rule({ ctx, width }: { ctx: Ctx; width?: number }) { return ; } // --- header ----------------------------------------------------------------------------------------------------- function Title({ ctx }: { ctx: Ctx }) { const { model, plan, v } = ctx; const h = plan.head; return ( {model.docTitle} ); } /** 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 ? ( {model.supplier.name} ) : ( ); const body = logo ? ( {name} ) : ( name ); return ( {body} ); } function MetaStack({ ctx }: { ctx: Ctx }) { const { plan, v } = ctx; const h = plan.head; return ( {h.lines.map((l, i) => ( {l.text} ))} ); } function HeaderRow({ ctx }: { ctx: Ctx }) { const { plan } = ctx; return ( <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} /> </> ); }