Files
Voiced/src/pdf/templates/ledger/plan.ts
T
xavierk 3aa0d33a01 Add units of measure and fractional quantities to rate lines
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
2026-10-06 09:35:20 +05:30

295 lines
13 KiB
TypeScript

import { colsDetailed, 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 { gridPadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
import type { RenderModel } from "../../model/build";
import type { TypeToken } from "../../fonts/roles";
import type { PlacedLogo } from "../contract";
import { bankLines, CANCELLED_SENTENCE, CLOSING_GAP, elastic, rateCell } from "../neutral/plan";
import type { LedgerVariant } from "./variant";
export const WORDS_GAP = 10;
export const REVERSE_GAP = 4;
/** Space between the closing block's heading and its content. */
export const HEADING_AFTER = 4;
/** Space between the tail's left column (notes) and the totals box. */
export const TAIL_COLUMN_GAP = 16;
const MIN_NAME_W = 40;
/** Narrowest the title may be squeezed to share the row with a brand block. */
const MIN_BRAND_W = 90;
const round2 = (n: number) => Math.round(n * 100) / 100;
const leading = (t: TypeToken) => t.leading as number;
export function metaLines(model: RenderModel): string[] {
const label = model.meta.numberLabel === "Invoice No." ? "Invoice Number" : model.meta.numberLabel;
const lines = [`${label}: ${model.meta.number}`, `Invoice Date: ${model.meta.date}`];
if (model.meta.dueDate) lines.push(`Due Date: ${model.meta.dueDate}`);
if (model.client.poNumber) lines.push(`PO No: ${model.client.poNumber}`);
return lines;
}
export function signatureOptions(v: LedgerVariant): SignatoryOptions {
const r = v.roles;
return {
roles: { name: r.bodyStrong, designation: r.small, fine: r.fine },
align: "left",
rule: { width: v.rule, color: v.ink },
color: v.ink,
imageHeight: v.signatureImageH,
};
}
export interface TotalsRowSpec {
label: string;
amount: string;
kind: "plain" | "total";
}
export interface LedgerPlan {
v: LedgerVariant;
W: number;
leftW: number;
rightW: number;
gaps: { rule: number; meta: number; party: number; table: number; tail: number; notes: number };
title: { token: TypeToken; w: number };
brand: {
w: number;
showName: boolean;
nameW: number;
nameH: number;
/** Offsets inside the brand block (which starts at the zone's top). */
logoTop: number;
nameTop: number;
nameLeft: number;
};
headerH: number;
meta: string[];
columns: ColSpec[];
header: string[];
rows: TableCellValue[][];
align: Array<"left" | "center" | "right">;
table: ItemsTablePlan;
guardH: number;
totals: { labelW: number; valueW: number; boxW: number; rowH: number; totalRowH: number; rows: TotalsRowSpec[]; slotRows: number; boxH: number };
notesBeside: boolean;
notesW: number;
wordsH: number;
reverseH: number;
tailH: number;
closingH: number;
payH: number;
signH: number;
signW: number;
}
/**
* Everything the layout must know before rendering: the brand block, columns, the totals box and the
* predicted heights of the totals tail and the closing block (their minPresenceAhead values), each a hair
* high rather than low.
*/
export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVariant, logo: PlacedLogo | null, includesName: boolean): LedgerPlan {
const m = getMeasurer();
const r = v.roles;
const W = frame.contentWidth;
const hsn = Slot.HsnSac(model);
// --- header: title and brand block ------------------------------------------------------------------
const markW = logo?.w ?? 0;
const showName = !(logo && includesName) && model.supplier.name.trim() !== "";
const titleNatural = Math.ceil(measureToken(m, r.title, model.docTitle)) + 1;
const brandCap = Math.floor(W * v.brandMaxShare);
const minBrand = Math.max(markW, MIN_BRAND_W);
const titleW = Math.min(titleNatural, W - v.brandGap - minBrand);
const brandAvail = Math.min(brandCap, W - titleW - v.brandGap);
const markBlock = markW > 0 && showName ? markW + v.markGap : markW;
let nameW = 0;
let nameH = 0;
if (showName) {
const natural = Math.ceil(measureToken(m, r.brand, model.supplier.name)) + 1;
nameW = Math.max(MIN_NAME_W, Math.min(natural, brandAvail - markBlock));
nameH = textHeight(r.brand, model.supplier.name, nameW);
}
const brandW = markBlock + nameW;
const zone = v.brandZone;
const logoTop = logo ? (logo.slotOffset?.y ?? 0) : 0;
const nameTop = logo ? Math.max(0, logoTop + (logo.h - nameH) / 2) : Math.max(0, (zone.h - nameH) / 2);
const brandBottom = zone.top + Math.max(logo ? logoTop + logo.h : 0, showName ? nameTop + nameH : 0);
const headerH = Math.max(v.titleTop + leading(r.title), zone.top + zone.h, brandBottom);
// --- parties ---------------------------------------------------------------------------------------
const leftW = round2(W * v.partyLeftShare);
const rightW = round2(W - leftW - v.partyGap);
const s = model.supplier;
let supplierH = textHeight(r.label, s.name, leftW) + v.headingGap;
for (const l of s.addressLines) supplierH += textHeight(r.body, l, leftW);
supplierH += ([s.email, s.phone, s.pan].filter(Boolean).length + slotHeights.gstinLines(model, "supplier")) * leading(r.body);
const c = model.client;
let clientH = leading(r.label) + v.headingGap + textHeight(r.body, c.name, rightW);
for (const l of c.addressLines) clientH += textHeight(r.body, l, rightW);
clientH += (slotHeights.gstinLines(model, "client") + 0) * leading(r.body) + slotHeights.placeOfSupplyLines(model, r.body, rightW) * leading(r.body);
const partyH = Math.max(supplierH, clientH);
const meta = metaLines(model);
const metaH = meta.length * leading(r.body) + (meta.length - 1) * v.metaGap;
// --- table -------------------------------------------------------------------------------------------
const hsnCol = hsn;
const ruleCols = (i: number) => (i === 0 ? 2 : 1) * v.rule;
const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
const colW = (base: number, texts: string[], headText: string, idx: number) =>
Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + ruleCols(idx) + 1);
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
const nCols = hsnCol ? 5 : 4;
const idx = (base: number) => (hsnCol ? base + 1 : base);
const c0 = {
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnCol.header, 1) : 0,
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "Unit Price", idx(1)),
qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", idx(2)),
amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "Amount", idx(3)),
};
const columns: ColSpec[] = [{ fill: true }, ...(hsnCol ? [{ fixed: c0.hsn } as ColSpec] : []), { fixed: c0.rate }, { fixed: c0.qty }, { fixed: c0.amount }];
const header = ["Item & Description", ...(hsnCol ? [hsnCol.header] : []), "Unit Price", "Qty", "Amount"];
const align: LedgerPlan["align"] = ["left", ...(hsnCol ? (["left"] as const) : []), "right", "right", "right"];
const rateInner = c0.rate - 2 * v.padX - ruleCols(idx(1));
const tableRows: TableCellValue[][] = model.items.map((item, i) => {
const rate = rateCell(item, rateInner, r.tableBody);
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
return [item.descriptionLines, ...(hsnCol ? [hsnCol.values[i]] : []), rateParts, item.qtyText, item.amountText];
});
void nCols;
// --- totals box --------------------------------------------------------------------------------------
const t = model.totals;
const labelTexts = ["Sub-Total", "Discount", "Taxable value", "UTGST @ 14%"];
const labelW = Math.max(v.totals.labelW, Math.ceil(Math.max(widest(r.totals, labelTexts), widest(r.total, ["Total"]))) + 2 * v.padX + 2 * v.rule + 1);
const amounts = [t.subtotalText, t.totalText, t.discountText ? `- ${t.discountText}` : "", t.taxableText ?? ""];
const valueW = Math.max(v.totals.valueW, Math.ceil(Math.max(widest(r.totals, amounts), widest(r.total, [t.totalText]))) + 2 * v.padX + v.rule + 1);
const boxW = Math.min(labelW + valueW, Math.floor(W * v.totals.maxShare) + 40);
const boxRows: TotalsRowSpec[] = [{ label: "Sub-Total", amount: t.subtotalText, kind: "plain" }];
if (t.discountText) boxRows.push({ label: "Discount", amount: `- ${t.discountText}`, kind: "plain" });
if (t.taxableText) boxRows.push({ label: "Taxable value", amount: t.taxableText, kind: "plain" });
const slotRows = slotHeights.taxRowCount(model);
const rowH = leading(r.totals) + 2 * v.totals.padY + v.rule;
const totalRowH = leading(r.total) + 2 * v.totals.padY + v.rule;
const boxH = (boxRows.length + slotRows) * rowH + totalRowH + v.rule;
// --- tail: notes beside the box, then the words ------------------------------------------------------
const notesW = Math.max(0, W - boxW - TAIL_COLUMN_GAP);
const notesH = model.notesLines.length
? leading(r.label) + v.headingGap + model.notesLines.reduce((h, l) => h + textHeight(r.body, l, notesW), 0)
: 0;
const notesBeside = notesH > 0 && notesW >= 140 && notesH <= v.notesBesideMax;
const wordsH = slotHeights.amountInWords(model, W, { role: r.small, labelRole: r.wordsLabel });
const reverseH = slotHeights.reverseCharge(model, W, r.small);
const tailBody = (notesBeside ? Math.max(boxH, notesH) : boxH) + 1 + (wordsH ? WORDS_GAP + wordsH : 0) + (reverseH ? REVERSE_GAP + reverseH : 0);
// --- closing -----------------------------------------------------------------------------------------
const signW = Math.min(v.signatureW, rightW);
const lines = bankLines(model);
let payH = 0;
if (model.bank) {
payH = leading(r.label) + HEADING_AFTER;
for (const l of lines) payH += textHeight(r.small, l, leftW);
}
const signH = leading(r.label) + HEADING_AFTER + signatoryHeight(model, signW, signatureOptions(v));
let closingH = CLOSING_GAP + Math.max(payH, signH) + 1.5;
if (model.cancelled) closingH += 10 + textHeight(r.small, CANCELLED_SENTENCE, W);
// --- elastic gaps ------------------------------------------------------------------------------------
const ranges = { rule: v.headerRule ? v.gaps.rule : { nominal: 0, min: 0 }, meta: v.gaps.meta, party: v.gaps.party, table: v.gaps.table, tail: v.gaps.tail, notes: v.gaps.notes };
const nominalTail = ranges.tail.nominal + tailBody;
const table0 = planTable(model, v, W, columns, header, tableRows, nCols, frame, nominalTail);
const tableTotal = table0.headH + table0.rowHeights.reduce((a, b) => a + b, 0);
const ownNotes = model.notesLines.length > 0 && !notesBeside;
const ownNotesH = ownNotes ? leading(r.label) + v.headingGap + model.notesLines.reduce((h, l) => h + textHeight(r.body, l, W), 0) : 0;
const nominalTotal =
headerH +
ranges.rule.nominal +
(v.headerRule?.thickness ?? 0) +
ranges.meta.nominal +
metaH +
ranges.party.nominal +
partyH +
ranges.table.nominal +
tableTotal +
nominalTail +
(ownNotes ? ranges.notes.nominal + ownNotesH : 0) +
closingH;
const slack = Object.values(ranges).reduce((sum, rg) => sum + rg.nominal - rg.min, 0);
const overflow = nominalTotal - frame.contentHeight;
const deficit = Math.max(frame.heightDeficit, overflow > 0 && overflow <= slack ? overflow : 0);
const g = elastic(ranges, deficit);
const tailH = g.tail + tailBody;
const table = planTable(model, v, W, columns, header, tableRows, nCols, frame, tailH);
// Rows are unbreakable, so the guard asks for the whole first row (the shared 3-line cap leaves a header alone
// above a taller row); only a row beyond 40% of the page is capped, so it cannot push the table off every page.
// A one-row table has no previous row to carry the last row's own presence value (the row is first in its
// container, where react-pdf never breaks before it), so the guard also holds the tail.
const guardH = table.headH + 1 + Math.min(table.firstRowH, frame.contentHeight * 0.4) + (tableRows.length === 1 ? tailH : 0);
return {
v,
W,
leftW,
rightW,
gaps: { rule: g.rule, meta: g.meta, party: g.party, table: g.table, tail: g.tail, notes: g.notes },
title: { token: r.title, w: titleW },
brand: { w: brandW, showName, nameW, nameH, logoTop, nameTop, nameLeft: markBlock },
headerH,
meta,
columns,
header,
rows: tableRows,
align,
table,
guardH,
totals: { labelW, valueW, boxW, rowH, totalRowH, rows: boxRows, slotRows, boxH },
notesBeside,
notesW,
wordsH,
reverseH,
tailH,
closingH,
payH,
signH,
signW,
};
}
function planTable(
model: RenderModel,
v: LedgerVariant,
W: number,
columns: ColSpec[],
header: string[],
rows: TableCellValue[][],
nCols: number,
frame: PageFrame,
tailH: number,
): ItemsTablePlan {
const r = v.roles;
void model;
return planItemsTable({
columns,
gap: 0,
width: W,
header,
rows,
measurer: getMeasurer(),
roles: { head: r.tableHead, body: r.tableBody },
padX: v.padX,
padY: v.padY,
border: v.rule,
padRight: gridPadRight(nCols, v.rule),
// The header's rule above its cells.
headerTailH: v.rule,
tailH,
contentH: frame.contentHeight,
});
}
export { colsDetailed, textLines };