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 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 { DOME_BOX } from "../../decor/Asterisk";
|
||||
import { fitSize } from "../../blocks/FitText";
|
||||
@@ -68,13 +68,13 @@ export function idLines(role: TypeToken, text: string, width: number): number {
|
||||
/** 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 = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
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.rateText, rateW, body);
|
||||
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];
|
||||
@@ -193,12 +193,12 @@ export function planPurple(model: RenderModel, frame: PageFrame, v: PurpleVarian
|
||||
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.rateText));
|
||||
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, model.items.map((i) => i.qtyText), "QTY"),
|
||||
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"),
|
||||
};
|
||||
@@ -424,10 +424,10 @@ export function planCitrus(model: RenderModel, frame: PageFrame, v: CitrusVarian
|
||||
// --- table ----------------------------------------------------------------------------------------------------
|
||||
const t = v.table;
|
||||
const tableW = round2(W - t.left);
|
||||
const priced = model.items.map((i) => priceOf(i.rateText));
|
||||
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, ...model.items.map((i) => measureToken(m, r.tableBody, i.qtyText)), measureToken(m, r.headNum, "QTY"))) + 6;
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user