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
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { colsDetailed, type ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
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";
|
||||
@@ -140,13 +140,13 @@ export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVarian
|
||||
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 = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
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.rateText)), "Unit Price", idx(1)),
|
||||
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty", idx(2)),
|
||||
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 }];
|
||||
@@ -154,7 +154,7 @@ export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVarian
|
||||
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.rateText, rateInner, r.tableBody);
|
||||
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];
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user