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
295 lines
13 KiB
TypeScript
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 };
|