Quantities can now be time, count, distance, area, weight or volume, or a custom unit, instead of only
second/minute/hour/session/unit.
- Registry in src/lib/units.ts (mirrored in src-tauri/src/units.rs): grouped built-in units plus a custom unit of 1-12
characters. A rate line's unit is validated when an invoice or a preset is saved; migration M12 drops the old unit
allow-list from item_presets and keeps every row.
- Time is typed as h:mm: "4:30" hours counts as 4.5, "4:20" as 4.333... (kept exact, so rate x quantity rounds once, the
same in TypeScript and Rust). Other units take plain decimals up to 3 places. A bad quantity blocks issuing.
- The PDF quantity column shows the unit ("4.5 hr", "12.75 km"); the rate keeps "per hour" / "/hr". RenderItem carries
priceText, perText and rateShort, so no template parses the rate text with a regex over five hard-coded units.
- Quantity columns are sized from the widest word once a quantity with its unit is wider than 72 pt, so one long
custom unit cannot squeeze the description (the serenity and citrus-split tables overflowed in the new fixture).
Classic's quantity column is now content-sized.
- ERPNext: the default UOM map comes from the registry, with fractional-capable UOMs for time, distance, area, weight
and volume. A custom unit is sent as Nos. A quantity that is not exact to 3 decimals (4:20 hours) is still refused for
push, as before.
- Goldens and template thumbnails regenerated; the template harness has a "units" fixture with the longest texts.
Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
554 lines
31 KiB
TypeScript
554 lines
31 KiB
TypeScript
import type { ColSpec } from "../../engine/columns";
|
|
import type { PageFrame } from "../../engine/geometry";
|
|
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
|
|
import { textHeight, textLines } from "../../engine/textplan";
|
|
import { DOME_BOX } from "../../decor/Asterisk";
|
|
import { fitSize } from "../../blocks/FitText";
|
|
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
|
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
|
|
import type { TypeToken } from "../../fonts/roles";
|
|
import type { RenderModel } from "../../model/build";
|
|
import type { PlacedLogo } from "../contract";
|
|
import { bankLines, CANCELLED_SENTENCE, elastic, rateCell } from "../neutral/plan";
|
|
import type { CitrusVariant, PurpleVariant } from "./variant";
|
|
|
|
/** How both templates print a tax row's label: "CGST @ 9%". */
|
|
export const atRate = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label);
|
|
export const leading = (t: TypeToken) => t.leading as number;
|
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
|
|
|
/** Space between the client label and the name pill, and between the pill and the address lines. */
|
|
export const CLIENT_LABEL_GAP = 9;
|
|
export const CLIENT_PILL_GAP = 6;
|
|
/** Gap between a mark and the vendor name beside it. */
|
|
export const MARK_GAP = 8;
|
|
/** Gap between the stack's last row and the TOTAL pill. */
|
|
export const PILL_GAP = 8;
|
|
export const REVERSE_GAP = 4;
|
|
/** Width of a bullet dot and the space after it. */
|
|
export const BULLET = { dot: 3.8, textAt: 12.7 };
|
|
/** Rows of the bullet column are this far apart on top of their line height. */
|
|
export const BULLET_GAP = 3;
|
|
export const CLOSING_COLUMN_GAP = 12;
|
|
/** Space above terms that flow as their own block (more bullets than fit beside the signature). */
|
|
export const NOTES_FLOW_GAP = 14;
|
|
/** Top of the dome below the totals box top (reference: 565.3 against the subtotal row at 541.5). */
|
|
export const DOME_TOP = 22;
|
|
|
|
/** The notes as bullet lines: one bullet per note line. */
|
|
export const termsLines = (model: RenderModel): string[] => model.notesLines;
|
|
|
|
export interface ContactEntry {
|
|
icon: "phone" | "email" | "location";
|
|
lines: string[];
|
|
/** Identifiers (phone, e-mail) shrink instead of wrapping; free text wraps. */
|
|
id: boolean;
|
|
}
|
|
|
|
/** The supplier's phone, e-mail and address as an icon list. */
|
|
export function contactEntries(model: RenderModel): ContactEntry[] {
|
|
const s = model.supplier;
|
|
const out: ContactEntry[] = [];
|
|
if (s.phone) out.push({ icon: "phone", lines: [s.phone], id: true });
|
|
if (s.email) out.push({ icon: "email", lines: [s.email], id: true });
|
|
if (s.addressLines.length) out.push({ icon: "location", lines: s.addressLines, id: false });
|
|
return out;
|
|
}
|
|
|
|
export function signatureOptions(roles: { name: TypeToken; designation: TypeToken; fine: TypeToken }, imageHeight: number, color: string, align: "left" | "right"): SignatoryOptions {
|
|
return { roles, align, rule: { width: 0.75, color }, color, imageHeight };
|
|
}
|
|
|
|
/** Lines an identifier takes in IdText: one when it fits (shrinking to the role's minimum), else the chunked row, over-estimated. */
|
|
export function idLines(role: TypeToken, text: string, width: number): number {
|
|
const f = fitSize(role, text, Math.max(1, width));
|
|
return f.fits ? 1 : Math.max(2, Math.ceil(f.width / Math.max(1, width)) + 1);
|
|
}
|
|
|
|
/** Plain shrink-wrapped width of a one-line text. */
|
|
export const widthOf = (role: TypeToken, text: string, size?: number) => Math.ceil(measureToken(getMeasurer(), role, text, size ? { size } : {})) + 1;
|
|
|
|
export const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
|
|
|
|
/** The table's description + HSN/SAC sub-line, qty, rate (amount over its unit) and amount cells. */
|
|
export function tableRows(model: RenderModel, rateW: number, body: TypeToken, sub: TypeToken): TableCellValue[][] {
|
|
const hsn = Slot.HsnSac(model);
|
|
return model.items.map((item, i) => {
|
|
const rate = rateCell(item, rateW, body);
|
|
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: sub }] as CellPart[]);
|
|
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: sub }] : item.descriptionLines;
|
|
return [desc, item.qtyText, rateParts, item.amountText];
|
|
});
|
|
}
|
|
|
|
/** Width of a numeric column: the design's, or wider when the widest value or the header needs it. */
|
|
export function numericColW(base: number, body: TypeToken, head: TypeToken, texts: string[], headText: string, pad = 6): number {
|
|
const m = getMeasurer();
|
|
const widest = Math.max(0, ...texts.map((t) => measureToken(m, body, t)), measureToken(m, head, headText));
|
|
return Math.max(base, Math.ceil(widest) + pad);
|
|
}
|
|
|
|
// --- Purple Pop ---------------------------------------------------------------------------------------------
|
|
|
|
export interface PurplePlan {
|
|
kind: "purple";
|
|
v: PurpleVariant;
|
|
W: number;
|
|
head: {
|
|
innerW: number;
|
|
pillText: string;
|
|
pillSize: number;
|
|
pillW: number;
|
|
leftW: number;
|
|
showName: boolean;
|
|
nameW: number;
|
|
nameLeft: number;
|
|
boxH: number;
|
|
linesH: number;
|
|
h: number;
|
|
};
|
|
client: { cellW: number; wordmarkW: number; pillW: number; pillBoxH: number; lineW: number; clientH: number };
|
|
title: { size: number; tax: boolean; h: number; w: number; pad: number };
|
|
rowH: number;
|
|
band: { amountW: number; datesW: number; dateSize: number; lines: string[]; amountText: string; amountSize: number; h: number };
|
|
mode: "narrow" | "full";
|
|
tableLeft: number;
|
|
tableW: number;
|
|
columns: ColSpec[];
|
|
headerCells: string[];
|
|
rows: TableCellValue[][];
|
|
align: Array<"left" | "center" | "right">;
|
|
padY: number;
|
|
headPadY: number;
|
|
table: ItemsTablePlan;
|
|
guardH: number;
|
|
tail: { gutter: number; boxW: number; valueW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; stackRowH: number; stackH: number; wordsW: number; wordsH: number; reverseH: number; body: number };
|
|
/** `beside`: the bullets sit next to the signature in the closing block; otherwise they flow as their own block before it. */
|
|
terms: { lines: string[]; beside: boolean; fillW: number; bulletW: number; bulletsH: number; signH: number; h: number; flowW: number; flowBulletW: number; flowH: number };
|
|
footer: { bank: string[]; contacts: ContactEntry[]; bankH: number; contactH: number; addressH: number; textW: { contact: number; address: number }; h: number };
|
|
gaps: { client: number; rule: number; band: number; table: number; tail: number };
|
|
tailH: number;
|
|
closingH: number;
|
|
/** Page-space y of the first-page asterisk's top, or null when the table is full width. */
|
|
asteriskY: number | null;
|
|
}
|
|
|
|
export function planPurple(model: RenderModel, frame: PageFrame, v: PurpleVariant, logo: PlacedLogo | null, includesName: boolean): PurplePlan {
|
|
const m = getMeasurer();
|
|
const r = v.roles;
|
|
const W = frame.contentWidth;
|
|
|
|
// --- header: name (and the opt-in logo) at the left, the INVOICE NO pill at the right -------------------
|
|
const innerW = W - v.head.nameInset - v.head.pillInset;
|
|
const showName = !(logo && includesName) && model.supplier.name.trim() !== "";
|
|
const markW = logo?.w ?? 0;
|
|
const nameLeft = markW > 0 ? markW + (showName ? MARK_GAP : 0) : 0;
|
|
const label = model.meta.numberLabel.replace(/\.$/, "");
|
|
const pillText = `${label} : ${model.meta.number}`;
|
|
const pillTextMax = Math.max(40, innerW - 12 - nameLeft - v.head.minNameW - 2 * v.head.pillPadX);
|
|
const fit = fitSize(r.pill, pillText, pillTextMax);
|
|
const pillW = Math.ceil(fit.width) + 2 * v.head.pillPadX + 2;
|
|
const leftW = innerW - pillW - 12;
|
|
const nameW = showName ? Math.min(v.head.nameMaxW, leftW - nameLeft) : 0;
|
|
const nameH = showName ? textHeight(r.brand, model.supplier.name, nameW) : 0;
|
|
const boxH = Math.max(v.head.pillH, nameH, logo?.h ?? 0);
|
|
const s = model.supplier;
|
|
const linesH = (slotHeights.gstinLines(model, "supplier") + (s.pan ? 1 : 0)) * leading(r.small);
|
|
const headH = Math.max(boxH + linesH, v.head.pillH);
|
|
|
|
// --- client column and the wordmark -------------------------------------------------------------------------
|
|
const cellW = v.client.colW - v.client.colGap;
|
|
const c = model.client;
|
|
const pillNatural = widthOf(r.clientName, c.name);
|
|
const pillW2 = Math.min(cellW, pillNatural + 2 * v.client.pillPadX);
|
|
const pillLines = textLines(r.clientName, c.name, pillW2 - 2 * v.client.pillPadX);
|
|
const pillBoxH = Math.max(v.client.pillH, pillLines * leading(r.clientName) + 2 * ((v.client.pillH - leading(r.clientName)) / 2));
|
|
const lineW = cellW - v.client.inset;
|
|
let clientH = leading(r.label) + CLIENT_LABEL_GAP + pillBoxH + CLIENT_PILL_GAP;
|
|
for (const l of c.addressLines) clientH += textHeight(r.clientLine, l, lineW);
|
|
clientH += slotHeights.gstinLines(model, "client") * leading(r.clientLine) + slotHeights.placeOfSupplyLines(model, r.clientLine, lineW) * leading(r.clientLine);
|
|
|
|
const wordmarkW = W - v.client.colW - v.client.wordmarkInset;
|
|
const tax = model.docType === "tax_invoice";
|
|
const wmPad = Math.abs((r.wordmark.tracking ?? 0) * r.wordmark.size);
|
|
const wm = fitSize(r.wordmark, "Invoice", wordmarkW - wmPad);
|
|
const titleH = leading(r.wordmark) + (tax ? leading(r.taxCaption) + 2 : 0);
|
|
const rowH = Math.max(clientH, titleH);
|
|
|
|
// --- amount due and dates ---------------------------------------------------------------------------------
|
|
const bandInner = W - v.band.amountInset - v.band.datesInset;
|
|
const dateLines = [`Invoice Date : ${model.meta.date}`, ...(model.meta.dueDate ? [`Due Date : ${model.meta.dueDate}`] : []), ...(c.poNumber ? [`PO : ${c.poNumber}`] : [])];
|
|
const widest = dateLines.reduce((a, b) => (widthOf(r.date, b) > widthOf(r.date, a) ? b : a));
|
|
const amountMin = 150;
|
|
const dFit = fitSize(r.date, widest, bandInner - v.band.gap - amountMin);
|
|
const datesW = Math.ceil(dFit.width) + 2;
|
|
const amountW = bandInner - v.band.gap - datesW;
|
|
const aFit = fitSize(r.amount, model.totals.totalText, amountW);
|
|
const bandH = Math.max(leading(r.label) + leading(r.amount) + 2, dateLines.length * leading(r.date));
|
|
|
|
// --- table: the narrow variant (beside the asterisk) or the full width ---------------------------------------
|
|
const t = v.table;
|
|
const rowsFor = (rateW: number) => tableRows(model, rateW, r.tableBody, r.tableSub);
|
|
const headerCells = ["Service", "Qty", "Rate", "Amount"];
|
|
const align: PurplePlan["align"] = ["left", "center", "right", "right"];
|
|
const padY = (t.rowH - leading(r.tableBody)) / 2;
|
|
const headPadY = (t.headH - leading(r.tableHead)) / 2;
|
|
const priced = model.items.map((i) => priceOf(i));
|
|
const build = (left: number, base: { qty: number; rate: number; amount: number }) => {
|
|
const tableW = round2(W - left - t.right);
|
|
const inner = tableW - 2 * t.inset;
|
|
const cw = {
|
|
qty: numericColW(base.qty, r.tableBody, r.tableHead, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "QTY"),
|
|
rate: numericColW(base.rate, r.tableBody, r.tableHead, priced, "RATE"),
|
|
amount: numericColW(base.amount, r.tableBody, r.tableHead, model.items.map((i) => i.amountText), "AMOUNT"),
|
|
};
|
|
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
|
|
const rows = rowsFor(cw.rate - 2);
|
|
return { tableW, inner, cw, columns, rows, descW: inner - cw.qty - cw.rate - cw.amount };
|
|
};
|
|
const plan = (b: ReturnType<typeof build>, tailH: number) =>
|
|
planItemsTable({
|
|
columns: b.columns,
|
|
gap: 0,
|
|
width: b.inner,
|
|
header: headerCells,
|
|
rows: b.rows,
|
|
measurer: m,
|
|
roles: { head: r.tableHead, body: r.tableBody },
|
|
padX: 0,
|
|
padY,
|
|
headPadY,
|
|
border: 0,
|
|
tailH,
|
|
contentH: frame.contentHeight,
|
|
});
|
|
const narrowB = build(t.narrowLeft, t.cols);
|
|
let mode: "narrow" | "full" = "full";
|
|
if (model.items.length <= 3 && narrowB.descW >= t.narrowDescMin) {
|
|
const probe = plan(narrowB, 0);
|
|
if (probe.rowLines.every((n) => n <= 2)) mode = "narrow";
|
|
}
|
|
const fullB = build(t.fullLeft, { qty: 46, rate: 100, amount: 108 });
|
|
const b = mode === "narrow" ? narrowB : fullB;
|
|
const tableLeft = mode === "narrow" ? t.narrowLeft : t.fullLeft;
|
|
|
|
// --- totals: the stack, the TOTAL pill, amount in words ---------------------------------------------------------
|
|
const tt = model.totals;
|
|
const stack: Array<{ label: string; amount: string }> = [{ label: "Subtotal", amount: tt.subtotalText }];
|
|
if (tt.discountText) stack.push({ label: "Discount", amount: `- ${tt.discountText}` });
|
|
if (tt.taxableText) stack.push({ label: "Taxable value", amount: tt.taxableText });
|
|
const slotRows = slotHeights.taxRowCount(model);
|
|
const gutter = round2(W - v.totals.boxW);
|
|
const boxW = v.totals.boxW;
|
|
const taxTexts = slotHeights.taxRowTexts(model, atRate);
|
|
const valueTexts = [tt.subtotalText, tt.totalText, ...(tt.discountText ? [`- ${tt.discountText}`] : []), ...(tt.taxableText ? [tt.taxableText] : []), ...taxTexts.map((x) => x.amount)];
|
|
const valueW = Math.max(100, Math.ceil(Math.max(...valueTexts.map((x) => measureToken(m, r.totalValue, x)))) + 2);
|
|
const stackRowH = leading(r.totalLabel) + v.totals.rowGap;
|
|
const stackH = (stack.length + slotRows) * stackRowH;
|
|
const wordsW = boxW - v.totals.labelInset - v.totals.pillValueInset;
|
|
const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.words, labelRole: r.wordsLabel });
|
|
const reverseH = slotHeights.reverseCharge(model, wordsW, r.small);
|
|
const boxBody = stackH + PILL_GAP + v.totals.pillH + (wordsH ? v.totals.wordsGap + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0);
|
|
// The dome in the left gutter must not make the row taller than its totals box.
|
|
const tailBody = Math.max(boxBody, DOME_TOP + DOME_BOX.h * (v.dome.w / DOME_BOX.w));
|
|
|
|
// --- closing: terms and signature, the rule, the three footer columns ----------------------------------------------
|
|
const lines = termsLines(model);
|
|
const signW = v.terms.signW;
|
|
const fillW = W - (v.terms.labelInset + v.terms.labelW) - signW - 2 * CLOSING_COLUMN_GAP;
|
|
const bulletW = fillW - BULLET.textAt;
|
|
const bulletsH = lines.reduce((h, l) => h + textHeight(r.bullet, l, bulletW) + BULLET_GAP, 0) - (lines.length ? BULLET_GAP : 0);
|
|
const sOpts = signatureOptions({ name: r.signName, designation: r.small, fine: r.fine }, v.signatureImageH, v.ink, "right");
|
|
const signH = signatoryHeight(model, signW, sOpts);
|
|
const beside = lines.length === 0 || bulletsH <= v.terms.besideMax;
|
|
const flowW = W - v.terms.labelInset - 7;
|
|
const flowBulletW = flowW - BULLET.textAt;
|
|
const flowH = beside ? 0 : leading(r.termsLabel) + 4 + lines.reduce((h, l) => h + textHeight(r.bullet, l, flowBulletW) + BULLET_GAP, 0) - BULLET_GAP;
|
|
const termsH = beside ? Math.max(lines.length ? leading(r.termsLabel) : 0, bulletsH, signH) : signH;
|
|
const bank = bankLines(model);
|
|
const bankH = model.bank ? leading(r.label) + 4 + bank.reduce((h, l) => h + textHeight(r.bank, l, v.footer.bankW), 0) : 0;
|
|
const contacts = contactEntries(model);
|
|
const contactText = v.footer.contactW - v.footer.iconW - v.footer.iconGap;
|
|
const addressText = W - v.footer.addressLeft - v.footer.iconW - v.footer.iconGap;
|
|
const phoneMail = contacts.filter((x) => x.icon !== "location");
|
|
const address = contacts.find((x) => x.icon === "location");
|
|
const contactH = phoneMail.reduce((h, x) => h + idLines(r.contact, x.lines[0], contactText) * leading(r.contact) + 1.5, 0);
|
|
const addressH = address ? address.lines.reduce((h, l) => h + textHeight(r.contact, l, addressText), 0) : 0;
|
|
const footerH = Math.max(bankH, contactH, addressH);
|
|
let closingH = v.gaps.termsAbove + termsH + v.gaps.ruleGap + v.rule.w + v.gaps.ruleGap + footerH + 1.5;
|
|
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
|
|
|
|
// --- gaps: the elastic ones give back Letter's missing height, or an overflow within their slack ------------------------
|
|
const ranges = { client: v.gaps.client, rule: v.gaps.rule, band: v.gaps.band, table: v.gaps.table, tail: v.gaps.tail };
|
|
const table0 = plan(b, 0);
|
|
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
|
|
const tailNominal = ranges.tail.nominal + tailBody;
|
|
const notesBlock = beside ? 0 : NOTES_FLOW_GAP + flowH;
|
|
const nominalTotal = headH + ranges.client.nominal + rowH + ranges.rule.nominal + v.rule.w + ranges.band.nominal + bandH + ranges.table.nominal + tableTotal + tailNominal + notesBlock + closingH;
|
|
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
|
const overflow = nominalTotal - frame.contentHeight;
|
|
// Everything on one page when the gaps can give back the overflow; failing that, keep the items and totals together
|
|
// on page 1 and let only the closing block move to the next page.
|
|
const overflowBeforeClosing = nominalTotal - closingH - frame.contentHeight;
|
|
const fits = overflow > 0 && overflow <= slack ? overflow : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? overflowBeforeClosing : 0;
|
|
const deficit = Math.max(frame.heightDeficit, fits);
|
|
const g = elastic(ranges, deficit);
|
|
const tailH = g.tail + tailBody;
|
|
const table = plan(b, tailH);
|
|
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (b.rows.length === 1 ? tailH : 0);
|
|
const asteriskY = mode === "narrow" ? round2(frame.pad.top + headH + g.client + rowH + g.rule + v.rule.w + g.band + bandH + g.table + v.asterisk.offsetY) : null;
|
|
|
|
return {
|
|
kind: "purple",
|
|
v,
|
|
W,
|
|
head: { innerW, pillText, pillSize: fit.size, pillW, leftW, showName, nameW, nameLeft, boxH, linesH, h: headH },
|
|
client: { cellW, wordmarkW, pillW: pillW2, pillBoxH, lineW, clientH },
|
|
title: { size: wm.size, tax, h: titleH, w: wordmarkW, pad: Math.abs((r.wordmark.tracking ?? 0) * wm.size) },
|
|
rowH,
|
|
band: { amountW, datesW, dateSize: dFit.size, lines: dateLines, amountText: model.totals.totalText, amountSize: aFit.size, h: bandH },
|
|
mode,
|
|
tableLeft,
|
|
tableW: b.tableW,
|
|
columns: b.columns,
|
|
headerCells,
|
|
rows: b.rows,
|
|
align,
|
|
padY,
|
|
headPadY,
|
|
table,
|
|
guardH,
|
|
tail: { gutter, boxW, valueW, stack, slotRows, stackRowH, stackH, wordsW, wordsH, reverseH, body: tailBody },
|
|
terms: { lines, beside, fillW, bulletW, bulletsH, signH, h: termsH, flowW, flowBulletW, flowH },
|
|
footer: { bank, contacts, bankH, contactH, addressH, textW: { contact: contactText, address: addressText }, h: footerH },
|
|
gaps: g,
|
|
tailH,
|
|
closingH,
|
|
asteriskY,
|
|
};
|
|
}
|
|
|
|
// --- Citrus Split --------------------------------------------------------------------------------------------
|
|
|
|
export interface CitrusPlan {
|
|
kind: "citrus";
|
|
v: CitrusVariant;
|
|
W: number;
|
|
/** The stripe's left edge from the content edge. */
|
|
Lw: number;
|
|
panelW: number;
|
|
/** The client block above the table (an invoice longer than a page), instead of beside the totals. */
|
|
clientAbove: boolean;
|
|
head: { titleW: number; titleSize: number; tax: boolean; titleH: number; showName: boolean; nameW: number; nameLeft: number; nameH: number; boxH: number; h: number };
|
|
client: { w: number; h: number; nameW: number };
|
|
meta: { noW: number; dateW: number; dueW: number; amountW: number; spacer: number; hasDue: boolean; h: number };
|
|
tableLeft: number;
|
|
tableW: number;
|
|
columns: ColSpec[];
|
|
headerCells: TableCellValue[];
|
|
rows: TableCellValue[][];
|
|
align: Array<"left" | "center" | "right">;
|
|
padLeft: number[];
|
|
padRight: number[];
|
|
padY: number;
|
|
headPadY: number;
|
|
cw: { qty: number; rate: number; amount: number };
|
|
table: ItemsTablePlan;
|
|
guardH: number;
|
|
tail: { leftW: number; valueW: number; barValueW: number; stack: Array<{ label: string; amount: string }>; slotRows: number; stackRowH: number; stackH: number; panelH: number; leftH: number; body: number; wordsW: number; wordsH: number; reverseH: number };
|
|
closing: {
|
|
bank: string[];
|
|
terms: string[];
|
|
/** Terms in the closing's left column; otherwise a flowing block before the closing. */
|
|
termsBeside: boolean;
|
|
flowW: number;
|
|
flowBulletW: number;
|
|
flowH: number;
|
|
leftH: number;
|
|
bankH: number;
|
|
termsH: number;
|
|
bulletW: number;
|
|
contacts: ContactEntry[];
|
|
contactTextW: number;
|
|
contactsH: number;
|
|
idLinesH: number;
|
|
signH: number;
|
|
rightH: number;
|
|
h: number;
|
|
};
|
|
gaps: { client: number; meta: number; table: number; tail: number };
|
|
tailH: number;
|
|
closingH: number;
|
|
}
|
|
|
|
export function planCitrus(model: RenderModel, frame: PageFrame, v: CitrusVariant, logo: PlacedLogo | null, includesName: boolean): CitrusPlan {
|
|
const m = getMeasurer();
|
|
const r = v.roles;
|
|
const W = frame.contentWidth;
|
|
const panelW = v.stripeW;
|
|
const Lw = round2(W - panelW);
|
|
const c = model.client;
|
|
const s = model.supplier;
|
|
|
|
// --- header: the wordmark at the left, the mark and name inside the panel ---------------------------------
|
|
const tax = model.docType === "tax_invoice";
|
|
const titleW = Lw - 8;
|
|
const tFit = fitSize(r.title, "Invoice", titleW);
|
|
const titleH = leading(r.title) + (tax ? leading(r.taxCaption) + 2 : 0);
|
|
const showName = !(logo && includesName) && s.name.trim() !== "";
|
|
const markW = logo?.w ?? 0;
|
|
const nameLeft = v.head.panelInset + (markW > 0 ? markW + (showName ? v.head.markGap : 0) : 0);
|
|
const nameW = showName ? Math.max(60, panelW - nameLeft - 4) : 0;
|
|
const nameH = showName ? textHeight(r.brand, s.name, nameW) : 0;
|
|
const boxH = Math.max(logo?.h ?? 0, nameH);
|
|
const headH = Math.max(titleH, boxH);
|
|
|
|
// --- client block -------------------------------------------------------------------------------------------
|
|
const cw = Lw - v.client.inset - 8;
|
|
let clientH = leading(r.billedLabel) + v.client.labelGap + textHeight(r.clientName, c.name, cw) + v.client.nameGap;
|
|
for (const l of c.addressLines) clientH += textHeight(r.clientLine, l, cw);
|
|
clientH += slotHeights.gstinLines(model, "client") * leading(r.clientLine) + slotHeights.placeOfSupplyLines(model, r.clientLine, cw) * leading(r.clientLine);
|
|
if (c.poNumber) clientH += textHeight(r.clientLine, `PO: ${c.poNumber}`, cw);
|
|
|
|
// --- meta row ----------------------------------------------------------------------------------------------
|
|
const mt = v.meta;
|
|
const hasDue = Boolean(model.meta.dueDate);
|
|
const noLabel = `${model.meta.numberLabel.replace(/\.$/, "")} :`;
|
|
const noW = Math.min(Lw - mt.inset - mt.dateMin, Math.max(mt.noMin, widthOf(r.metaLabel, noLabel) + 8, Math.ceil(measureToken(m, r.metaValue, model.meta.number)) + 10));
|
|
const dateW = round2(Lw - mt.inset - noW);
|
|
const spacer = mt.panelInset;
|
|
const dueW = mt.dueW;
|
|
const amountW = round2(panelW - spacer - dueW);
|
|
const metaH = leading(r.metaLabel) + mt.labelGap + leading(r.metaValue);
|
|
|
|
// --- table ----------------------------------------------------------------------------------------------------
|
|
const t = v.table;
|
|
const tableW = round2(W - t.left);
|
|
const priced = model.items.map((i) => priceOf(i));
|
|
const amountNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.amountText)), measureToken(m, r.headNum, "AMOUNT"))) + 14 + 4;
|
|
const rateNeed = Math.ceil(Math.max(0, ...priced.map((p) => measureToken(m, r.tableBody, p)), measureToken(m, r.headNum, "RATE"))) + 14 + 4;
|
|
const qtyNeed = Math.ceil(Math.max(0, ...wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody).map((t) => measureToken(m, r.tableBody, t)), measureToken(m, r.headNum, "QTY"))) + 6;
|
|
const amount = Math.max(t.cols.amount, amountNeed);
|
|
const rate = Math.max(t.cols.rate, rateNeed);
|
|
const qty = Math.max(Math.min(36, panelW), round2(panelW - rate - amount));
|
|
const cwid = { qty: Math.max(qty, qtyNeed), rate, amount: round2(panelW - Math.max(qty, qtyNeed) - rate) };
|
|
const columns: ColSpec[] = [{ fill: true }, { fixed: cwid.qty }, { fixed: cwid.rate }, { fixed: cwid.amount }];
|
|
const headerCells: TableCellValue[] = [[{ text: "Service", role: r.headService }], [{ text: "Qty", role: r.headNum }], [{ text: "Rate", role: r.headNum }], [{ text: "Amount", role: r.headNum }]];
|
|
const align: CitrusPlan["align"] = ["left", "center", "right", "right"];
|
|
const padLeft = [t.bodyInset, 0, 0, 0];
|
|
const padRight = [t.descRight, 0, 14, 13.6];
|
|
const headPadY = (t.headH - leading(r.headNum)) / 2;
|
|
const rows = tableRows(model, cwid.rate - 14 - 2, r.tableBody, r.tableSub);
|
|
const planTable = (padY: number, tailH: number) =>
|
|
planItemsTable({ columns, gap: 0, width: tableW, header: headerCells, rows, measurer: m, roles: { head: r.headNum, body: r.tableBody }, padX: 0, padLeft, padRight, padY, headPadY, border: 0, tailH, contentH: frame.contentHeight });
|
|
|
|
// --- totals panel and the tail row ----------------------------------------------------------------------------
|
|
const tt = model.totals;
|
|
const stack: Array<{ label: string; amount: string }> = [{ label: "Subtotal", amount: tt.subtotalText }];
|
|
if (tt.discountText) stack.push({ label: "Discount", amount: `- ${tt.discountText}` });
|
|
if (tt.taxableText) stack.push({ label: "Taxable value", amount: tt.taxableText });
|
|
const slotRows = slotHeights.taxRowCount(model);
|
|
const taxTexts = slotHeights.taxRowTexts(model, atRate);
|
|
const valueTexts = [tt.subtotalText, ...(tt.discountText ? [`- ${tt.discountText}`] : []), ...(tt.taxableText ? [tt.taxableText] : []), ...taxTexts.map((x) => x.amount)];
|
|
const innerTotals = panelW - v.totals.labelInset - v.totals.valueInset;
|
|
const stackLabels = [...stack.map((x) => x.label), ...taxTexts.map((x) => x.label)];
|
|
const widestLabel = Math.ceil(Math.max(...stackLabels.map((l) => measureToken(m, r.totalLabel, l)))) + 4;
|
|
// The amounts take their own width; the labels get the rest and never wrap (the row height is one line).
|
|
const valueW = Math.min(innerTotals - widestLabel, Math.max(64, Math.ceil(Math.max(...valueTexts.map((x) => measureToken(m, r.totalValue, x)))) + 2));
|
|
const barValueW = Math.max(valueW, Math.ceil(Math.max(measureToken(m, r.grandValue, tt.totalText), 0)) + 2);
|
|
const stackRowH = leading(r.totalLabel) + v.totals.rowGap;
|
|
const stackH = (stack.length + slotRows) * stackRowH;
|
|
// Amount in words sits under the TOTAL bar (the design's tagline slot), right-aligned.
|
|
const wordsW = panelW - 2 * 13.1;
|
|
const wordsH = slotHeights.amountInWords(model, wordsW, { role: r.words, labelRole: r.wordsLabel });
|
|
const reverseH = slotHeights.reverseCharge(model, wordsW, r.small);
|
|
const panelTail = v.separator.h + v.totals.sepGap + stackH + v.totals.barGap + v.totals.barH + (wordsH ? v.totals.wordsGap + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0);
|
|
const tailLeftW = Lw - t.left;
|
|
const leftTail = v.totals.sepGap + clientH;
|
|
|
|
// --- closing: method and terms at the left; words, contacts, signature in the panel column --------------------------
|
|
const cl = v.closing;
|
|
const bank = bankLines(model);
|
|
const bankH = model.bank ? leading(r.sectionLabel) + cl.labelGap + bank.reduce((h, l) => h + textHeight(r.bank, l, Lw - cl.bankInset - 8), 0) : 0;
|
|
const terms = termsLines(model);
|
|
const bulletW = Lw - cl.bulletText - 8;
|
|
const termsH = terms.length ? leading(r.sectionLabel) + cl.labelGap + terms.reduce((h, l) => h + textHeight(r.bullet, l, bulletW), 0) + (terms.length - 1) * cl.bulletGap : 0;
|
|
const termsBeside = terms.length === 0 || termsH <= cl.termsBesideMax;
|
|
const flowW = W - cl.methodInset - 8;
|
|
const flowBulletW = flowW - (cl.bulletText - cl.methodInset);
|
|
const flowH = termsBeside ? 0 : leading(r.sectionLabel) + cl.labelGap + terms.reduce((h, l) => h + textHeight(r.bullet, l, flowBulletW), 0) + (terms.length - 1) * cl.bulletGap;
|
|
const leftH = bankH + (termsBeside && bankH && termsH ? cl.sectionGap : 0) + (termsBeside ? termsH : 0);
|
|
const contacts = contactEntries(model);
|
|
const contactTextW = panelW - (cl.iconGap + cl.iconSize + cl.iconInset) - 8;
|
|
const contactsH = contacts.reduce((h, x) => h + (x.id ? idLines(r.contact, x.lines[0], contactTextW) * leading(r.contact) : x.lines.reduce((a, l) => a + textHeight(r.contact, l, contactTextW), 0)) + cl.contactGap, 0) - (contacts.length ? cl.contactGap : 0);
|
|
const idLinesH = (slotHeights.gstinLines(model, "supplier") + (s.pan ? 1 : 0)) * leading(r.small);
|
|
const sOpts = signatureOptions({ name: r.signName, designation: r.small, fine: r.fine }, v.signatureImageH, v.text, "right");
|
|
const signH = signatoryHeight(model, cl.signW, sOpts);
|
|
const parts = [contactsH + (idLinesH ? 4 + idLinesH : 0), signH].filter((x) => x > 0);
|
|
const rightH = parts.reduce((a, b) => a + b, 0) + (parts.length - 1) * 12;
|
|
let closingH = cl.clientTop + Math.max(leftH, rightH) + 1.5;
|
|
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
|
|
|
|
// --- gaps. The client block sits beside the totals when the invoice fits one page, above the table otherwise. -------------
|
|
const padYn = t.padY;
|
|
const n = rows.length;
|
|
const run = (clientAbove: boolean) => {
|
|
const tailBody = Math.max(panelTail, clientAbove ? 0 : leftTail);
|
|
const ranges = {
|
|
client: clientAbove ? v.gaps.client : { nominal: 0, min: 0 },
|
|
meta: clientAbove ? { nominal: 24, min: 12 } : v.gaps.meta,
|
|
table: v.gaps.table,
|
|
tail: v.gaps.tail,
|
|
rowPad: { nominal: 2 * padYn * n, min: 2 * t.padYMin * n },
|
|
};
|
|
const table0 = planTable(padYn, 0);
|
|
const tableTotal = table0.headH + table0.rowHeights.reduce((a, x) => a + x, 0);
|
|
const nominalTotal = headH + (clientAbove ? ranges.client.nominal + clientH : 0) + ranges.meta.nominal + metaH + ranges.table.nominal + tableTotal + ranges.tail.nominal + tailBody + (termsBeside ? 0 : NOTES_FLOW_GAP + flowH) + closingH;
|
|
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
|
|
return { tailBody, ranges, nominalTotal, slack, tableTotal };
|
|
};
|
|
const faithful = run(false);
|
|
const clientAbove = faithful.nominalTotal - faithful.slack > frame.contentHeight;
|
|
const chosen = clientAbove ? run(true) : faithful;
|
|
const { tailBody, ranges, nominalTotal, slack } = chosen;
|
|
const overflow = nominalTotal - frame.contentHeight;
|
|
const overflowBeforeClosing = nominalTotal - closingH - frame.contentHeight;
|
|
const fits = overflow > 0 && overflow <= slack ? overflow : overflowBeforeClosing > 0 && overflowBeforeClosing <= slack ? overflowBeforeClosing : 0;
|
|
const deficit = Math.max(frame.heightDeficit, fits);
|
|
const g = elastic(ranges, deficit);
|
|
const padY = n ? g.rowPad / (2 * n) : padYn;
|
|
const tailH = g.tail + tailBody;
|
|
const table = planTable(padY, tailH);
|
|
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (rows.length === 1 ? tailH : 0);
|
|
|
|
return {
|
|
kind: "citrus",
|
|
v,
|
|
W,
|
|
Lw,
|
|
panelW,
|
|
clientAbove,
|
|
head: { titleW, titleSize: tFit.size, tax, titleH, showName, nameW, nameLeft, nameH, boxH, h: headH },
|
|
client: { w: cw, h: clientH, nameW: cw },
|
|
meta: { noW, dateW, dueW, amountW, spacer, hasDue, h: metaH },
|
|
tableLeft: t.left,
|
|
tableW,
|
|
columns,
|
|
headerCells,
|
|
rows,
|
|
align,
|
|
padLeft,
|
|
padRight,
|
|
padY,
|
|
headPadY,
|
|
cw: cwid,
|
|
table,
|
|
guardH,
|
|
tail: { leftW: tailLeftW, valueW, barValueW, stack, slotRows, stackRowH, stackH, panelH: panelTail, leftH: leftTail, body: tailBody, wordsW, wordsH, reverseH },
|
|
closing: { bank, terms, termsBeside, flowW, flowBulletW, flowH, leftH, bankH, termsH, bulletW, contacts, contactTextW, contactsH, idLinesH, signH, rightH, h: closingH },
|
|
gaps: { client: g.client, meta: g.meta, table: g.table, tail: g.tail },
|
|
tailH,
|
|
closingH,
|
|
};
|
|
}
|