import { Image, View } from "@react-pdf/renderer"; import type { ReactNode } from "react"; import { colsDetailed, pinned } from "../../engine/columns"; import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, 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 { Cell, Row } from "../../blocks/Row"; import { SafeTxt } from "../../blocks/SafeTxt"; import { pdfFlags } from "../../blocks/pdfProps"; import { Slot } from "../../blocks/slots"; import { TableGuard } from "../../blocks/TableGuard"; import { Txt } from "../../blocks/Txt"; import { ContactIcon } from "../../decor/CircledIcon"; 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 type { CellPart, TableCellValue } from "../../blocks/ItemsTable"; import { LABEL_GAP, NOTES_FLOW_GAP, paymentLines, planHighlighter, REVERSE_GAP, signatureOptions, taxLabel, type HighlighterPlan } from "./plan"; import { BLACK, INDIGO, YELLOW, type HighlighterVariant } from "./tokens"; /** Layout-tree ids of the blocks that are Highlighter's own (the harness ids are in classic/ids.ts). */ export const PAY_ID = "highlighter-pay"; export const PAD_PREFIX = "highlighter-pad-"; /** 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. */ const NOTES_HOLD = 70; interface Ctx { model: TemplateProps["model"]; plan: HighlighterPlan; v: HighlighterVariant; logo: PlacedLogo | null; } function Spacer({ h }: { h: number }) { return h > 0 ? : null; } // --- header ----------------------------------------------------------------------------------------------------- function Title({ ctx }: { ctx: Ctx }) { const { model, plan, v } = ctx; const h = plan.head; return ( {model.docTitle} ); } /** The vendor at the top right: the logo (right-aligned at the slot's top), the name, GSTIN and PAN. */ function Vendor({ ctx }: { ctx: Ctx }) { const { model, plan, v, logo } = ctx; const h = plan.head; const r = v.roles; const s = model.supplier; return ( {logo ? : null} {h.showName ? ( {s.name} ) : null} {s.pan ? ( {`PAN: ${s.pan}`} ) : null} {model.cancelled ? ( CANCELLED ) : null} ); } function HeaderRow({ ctx }: { ctx: Ctx }) { const { plan, v } = ctx; return ( <Vendor ctx={ctx} /> </Row> ); } // --- fields and payment ----------------------------------------------------------------------------------------- function Field({ ctx, index }: { ctx: Ctx; index: number }) { const { plan, v } = ctx; const f = v.fields; const spec = plan.fields.specs[index]; const labelW = plan.fields.labelW[index]; const valueW = f.w - labelW - f.labelGap; return ( <View style={{ marginTop: index > 0 ? f.rowGap : 0 }}> <Row widths={[{ fixed: labelW }, { fixed: valueW }]} gap={f.labelGap} width={f.w}> <Txt role={v.roles.label} color={v.ink} width={labelW}> {spec.label} </Txt> <View style={{ ...pinned(valueW), borderBottomWidth: v.hair, borderBottomColor: INDIGO } as never}> <AvailableWidthProvider width={valueW - f.valuePad}> <View style={{ marginLeft: f.valuePad }}> <SafeTxt role={v.roles.value} color={v.ink}> {spec.value} </SafeTxt> </View> </AvailableWidthProvider> </View> </Row> </View> ); } function Fields({ ctx }: { ctx: Ctx }) { const { model, plan, v } = ctx; const r = v.roles; const c = model.client; const f = v.fields; return ( <View id={META_ID} style={pinned(f.w) as never}> {plan.fields.specs.map((_, i) => ( <Field key={i} ctx={ctx} index={i} /> ))} {plan.fields.subH > 0 ? ( <View style={{ ...pinned(f.w), marginTop: f.subGap } as never}> <AvailableWidthProvider width={f.w}> {c.addressLines.map((line, i) => ( <SafeTxt key={i} role={r.sub} color={v.ink}> {line} </SafeTxt> ))} <Slot.Gstin model={model} party="client" role={r.sub} color={v.ink} /> <Slot.PlaceOfSupply model={model} role={r.sub} color={v.ink} /> {c.poNumber ? <SafeTxt role={r.sub} color={v.ink}>{`PO: ${c.poNumber}`}</SafeTxt> : null} </AvailableWidthProvider> </View> ) : null} </View> ); } function Payment({ ctx }: { ctx: Ctx }) { const { model, v } = ctx; const r = v.roles; const lines = paymentLines(model); if (lines.length === 0) return <View />; const w = v.pay.w; return ( <View id={PAY_ID} style={{ ...pinned(w), marginTop: v.pay.top } as never}> <AvailableWidthProvider width={w}> <Txt role={r.payHead} color={v.ink} width={w}> Payment Method: </Txt> {lines.map((line, i) => ( <SafeTxt key={i} role={r.payLine} color={v.ink}> {line} </SafeTxt> ))} </AvailableWidthProvider> </View> ); } // --- table ------------------------------------------------------------------------------------------------------ const partsOf = (value: TableCellValue): CellPart[] => (Array.isArray(value) ? (value.length ? value : [""]) : [value]); /** One table line inside the 3 pt frame: the frame's sides are the line's own borders, the dividers are its cells'. */ function Line({ ctx, id, last, flags, minH, children, }: { ctx: Ctx; id: string; last: boolean; flags: ReturnType<typeof pdfFlags>; minH?: number; children: ReactNode; }) { const { plan, v } = ctx; return ( <View id={id} {...flags} style={ { ...pinned(plan.W), borderLeftWidth: v.frame, borderRightWidth: v.frame, borderLeftColor: v.ink, borderRightColor: v.ink, borderBottomWidth: last ? v.frame : v.hair, borderBottomColor: last ? v.ink : INDIGO, } as never } > <Row widths={plan.columns} width={plan.innerW} align="stretch" style={minH ? { minHeight: minH } : undefined}> {children} </Row> </View> ); } function colInner(ctx: Ctx, i: number): number { const { plan, v } = ctx; const w = plan.table.widths[i]; const last = i === plan.columns.length - 1; return Math.max(0, w - plan.padLeft[i] - (plan.padRight[i] - (last ? 0 : v.hair)) - (last ? 0 : v.hair)); } function BodyCell({ ctx, i, children }: { ctx: Ctx; i: number; children?: ReactNode }) { const { plan, v } = ctx; const last = i === plan.columns.length - 1; return ( <Cell padding={{ top: plan.padY, bottom: plan.padY, left: plan.padLeft[i], right: plan.padRight[i] - (last ? 0 : v.hair) }} borderRight={last ? 0 : v.hair} style={{ flexGrow: 1, flexBasis: "auto", ...(last ? {} : { borderRightColor: INDIGO }) }} > {children} </Cell> ); } function DataRow({ ctx, index, lastRow }: { ctx: Ctx; index: number; lastRow: boolean }) { const { plan, v } = ctx; const r = v.roles; const cells = plan.rows[index]; return ( <Line ctx={ctx} id={`${TABLE_ROW_PREFIX}${index}`} last={lastRow} flags={pdfFlags({ wrap: false, minPresenceAhead: lastRow ? plan.tailH + (v.frame - v.hair) : undefined })}> {cells.map((value, i) => ( <BodyCell key={i} ctx={ctx} i={i}> {partsOf(value).map((part, li) => { const text = typeof part === "string" ? part : part.text; const role = typeof part === "string" ? r.tableBody : (part.role as typeof r.tableBody); return i === 0 && typeof part === "string" ? ( <SafeTxt key={li} role={role} color={v.ink}> {text} </SafeTxt> ) : ( <Txt key={li} role={role} color={v.ink} width={colInner(ctx, i)} align={plan.align[i]}> {text} </Txt> ); })} </BodyCell> ))} </Line> ); } function PadRow({ ctx, index, lastRow }: { ctx: Ctx; index: number; lastRow: boolean }) { const { plan, v } = ctx; return ( <Line ctx={ctx} id={`${PAD_PREFIX}${index}`} last={lastRow} flags={pdfFlags({ wrap: false, minPresenceAhead: lastRow ? plan.tailH + (v.frame - v.hair) : undefined })} minH={plan.padRowH - v.hair}> {plan.columns.map((_, i) => ( <BodyCell key={i} ctx={ctx} i={i} /> ))} </Line> ); } /** The header group, repeated on every page: the yellow bar, the gap, and the body's 3 pt top rule. */ function HeaderGroup({ ctx }: { ctx: Ctx }) { const { plan, v } = ctx; const t = v.table; const r = v.roles; const fr = v.frame; return ( <View {...pdfFlags({ fixed: true })} style={pinned(plan.W) as never}> <View id={TABLE_HEAD_ID} style={ { ...pinned(plan.W), backgroundColor: YELLOW, borderWidth: fr, borderColor: v.ink, } as never } > <Row widths={plan.columns} width={plan.innerW} align="stretch"> {plan.headerCells.map((text, i) => { const last = i === plan.columns.length - 1; return ( <Cell key={i} padding={{ top: t.barPadTop, bottom: t.barPadTop, left: 0, right: 0 }} borderRight={last ? 0 : v.hair} style={{ flexGrow: 1, flexBasis: "auto", ...(last ? {} : { borderRightColor: INDIGO }) }} > <Txt role={r.tableHead} color={v.ink} width={Math.max(0, plan.table.widths[i] - (last ? 0 : v.hair))} align="center"> {text} </Txt> </Cell> ); })} </Row> </View> <Spacer h={t.bodyGap} /> <View style={{ ...pinned(plan.W), height: fr, backgroundColor: v.ink } as never} /> </View> ); } function Table({ ctx }: { ctx: Ctx }) { const { plan } = ctx; const n = plan.rows.length; return ( <AvailableWidthProvider width={plan.W}> <View style={pinned(plan.W) as never}> <HeaderGroup ctx={ctx} /> {plan.rows.map((_, i) => ( <DataRow key={i} ctx={ctx} index={i} lastRow={i === n - 1 && plan.padRows === 0} /> ))} {Array.from({ length: plan.padRows }, (_, i) => ( <PadRow key={i} ctx={ctx} index={i} lastRow={i === plan.padRows - 1} /> ))} <Tail ctx={ctx} /> </View> </AvailableWidthProvider> ); } // --- totals ----------------------------------------------------------------------------------------------------- function TotalRow({ ctx, label, amount, total }: { ctx: Ctx; label: string; amount: string; total?: boolean }) { const { plan, v } = ctx; const t = plan.tail; const r = v.roles; const o = v.totals; const pad = { paddingTop: o.padY, paddingBottom: o.padY }; return ( <View style={{ flexDirection: "row", ...pinned(t.boxW - 2 * v.frame), ...(total ? {} : { borderBottomWidth: v.hair, borderBottomColor: INDIGO }) } as never}> <View style={ { ...pinned(t.labelW), ...pad, paddingLeft: o.labelPad, borderRightWidth: v.frame, borderRightColor: v.ink, ...(total ? { backgroundColor: YELLOW } : {}), } as never } > <Txt role={r.totalLabel} color={BLACK} width={t.labelW - v.frame - o.labelPad}> {label} </Txt> </View> <View style={{ ...pinned(t.valueW), ...pad, paddingRight: o.valuePad } as never}> <Txt role={total ? r.totalStrong : r.totalValue} color={v.ink} width={t.valueW - o.valuePad} align="right"> {amount} </Txt> </View> </View> ); } function TotalsBox({ ctx }: { ctx: Ctx }) { const { model, plan, v } = ctx; const t = plan.tail; return ( <View style={{ ...pinned(t.boxW), borderWidth: v.frame, borderColor: v.ink } as never}> <AvailableWidthProvider width={t.boxW - 2 * v.frame}> {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={taxLabel} renderRow={(row) => <TotalRow ctx={ctx} label={row.label} amount={row.amount} />} /> <TotalRow ctx={ctx} label="total :" amount={model.totals.totalText} total /> </AvailableWidthProvider> </View> ); } function Tail({ ctx }: { ctx: Ctx }) { const { model, plan, v } = ctx; const t = plan.tail; const r = v.roles; const o = v.totals; return ( <View id={TOTALS_ID} wrap={false} style={pinned(plan.W) as never}> <Spacer h={o.gap} /> <Row widths={[{ fill: true }, { fixed: t.boxW }]} width={plan.W}> <View style={{ ...pinned(t.wordsW), marginLeft: v.inset, paddingTop: t.leftH ? o.wordsTop : 0 } as never}> <AvailableWidthProvider width={t.wordsW}> <Slot.AmountInWords model={model} role={r.words} labelRole={r.wordsLabel} color={v.ink} /> {t.reverseH > 0 ? <Slot.ReverseCharge model={model} role={r.words} color={v.ink} style={{ marginTop: t.wordsH > 0 ? REVERSE_GAP : 0 }} /> : null} </AvailableWidthProvider> </View> <TotalsBox ctx={ctx} /> </Row> </View> ); } // --- closing ---------------------------------------------------------------------------------------------------- /** Notes in flow mode (too long for the ruled lines): a label held with the first line, every line unbreakable. */ function NotesFlow({ 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.wordsLabel} color={v.ink} width={width} style={{ marginBottom: LABEL_GAP }}> Notes: </Txt> <SafeTxt role={r.note} color={v.ink}> {lines[0]} </SafeTxt> </View> {lines.slice(1).map((line, i) => ( <SafeTxt key={i} role={r.note} color={v.ink} wrap={false}> {line} </SafeTxt> ))} </AvailableWidthProvider> </View> ); } function SignOff({ ctx }: { ctx: Ctx }) { const { plan, v } = ctx; const r = v.roles; const cl = v.closing; const c = plan.closing; const w = c.leftW - v.inset; return ( <View style={{ ...pinned(c.leftW), paddingLeft: v.inset, paddingTop: cl.leftTop } as never}> <AvailableWidthProvider width={w}> <Txt role={r.thanks} color={v.ink} width={w}> THANK YOU </Txt> <View style={{ marginTop: c.contacts.length ? cl.thanksGap : 0 }}> {c.contacts.map((x, i) => ( <View key={i} style={{ marginTop: i > 0 ? cl.contactGap : 0 }}> <Row widths={[{ fixed: cl.iconSize }, { fill: true }]} gap={cl.iconGap} width={w}> <View style={{ marginTop: 0.6 }}> <ContactIcon name={x.icon} size={cl.iconSize} color={v.ink} /> </View> {x.icon === "location" ? ( <SafeTxt role={r.contact} color={BLACK}> {x.text} </SafeTxt> ) : ( // A phone number or e-mail is an identifier: one fitted line (shrunk, then split at "@ . / -"), never a wrapped one. <IdText role={r.contact} color={BLACK}> {x.text} </IdText> )} </Row> </View> ))} </View> </AvailableWidthProvider> </View> ); } /** The design's ruled lines: the notes written on them (a line per wrapped line), then the signature on the last one. */ function Ruled({ ctx }: { ctx: Ctx }) { const { model, plan, v } = ctx; const r = v.roles; const cl = v.closing; const c = plan.closing; const opts = signatureOptions(v); return ( <View style={pinned(cl.ruledW) as never}> <AvailableWidthProvider width={cl.ruledW}> {Array.from({ length: c.ruledRows }, (_, i) => ( <View key={i} style={ { ...pinned(cl.ruledW), minHeight: cl.ruledH, paddingTop: cl.ruledH - v.hair - (r.note.leading as number), borderBottomWidth: v.hair, borderBottomColor: INDIGO, } as never } > {!c.notesFlow && c.ruled[i] !== undefined ? ( <FitText role={r.note} color={v.ink} width={cl.ruledW - 2}> {c.ruled[i]} </FitText> ) : null} </View> ))} <Slot.Signatory model={model} width={cl.ruledW} {...opts} /> </AvailableWidthProvider> </View> ); } function Closing({ ctx }: { ctx: Ctx }) { const { model, plan, v } = ctx; const cl = v.closing; const c = plan.closing; return ( <> <BottomSpacer closingH={plan.closingH} /> <View id={CLOSING_ID} wrap={false} style={pinned(plan.W) as never}> <AvailableWidthProvider width={plan.W}> <Spacer h={plan.gaps.close} /> <Row widths={[{ fixed: c.leftW }, { fixed: cl.ruledW }]} gap={cl.colGap} width={plan.W - v.rightInset}> <SignOff ctx={ctx} /> <Ruled ctx={ctx} /> </Row> {model.cancelled ? ( <Txt role={v.roles.note} color={CANCELLED_INK} width={plan.W} style={{ marginTop: 10, marginLeft: v.inset }}> {CANCELLED_SENTENCE} </Txt> ) : null} </AvailableWidthProvider> </View> </> ); } // --- the layout ------------------------------------------------------------------------------------------------- export function HighlighterBody({ model, frame, logo, prefs, variant: v }: TemplateProps & { variant: HighlighterVariant }) { const plan = planHighlighter(model, frame, v, logo, includesName({ model, prefs })); const ctx: Ctx = { model, plan, v, logo }; const notesFlow: ReactNode = !plan.closing.notesFlow ? null : ( <> <TableGuard {...{ minPresenceAhead: NOTES_FLOW_GAP + NOTES_HOLD }} /> <View style={{ marginTop: NOTES_FLOW_GAP, marginLeft: v.inset }}> <NotesFlow ctx={ctx} width={plan.W - v.inset - v.rightInset} /> </View> </> ); return ( <> <FirstPageHeader pad={frame.pad}> <HeaderRow ctx={ctx} /> <Spacer h={v.fields.top} /> <Row widths={[{ fixed: plan.fields.leftW }, { fixed: v.pay.w + v.rightInset }]} width={plan.W}> <View style={{ ...pinned(plan.fields.leftW), paddingLeft: v.inset } as never}> <Fields ctx={ctx} /> </View> <Payment ctx={ctx} /> </Row> </FirstPageHeader> <Spacer h={plan.gaps.bar} /> <TableGuard {...{ minPresenceAhead: plan.guardH }} /> <Table ctx={ctx} /> {notesFlow} <Closing ctx={ctx} /> </> ); }