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 } from "../../engine/textplan";
|
||||
import { fitSize } from "../../blocks/FitText";
|
||||
import { innerRulePadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
||||
@@ -19,7 +19,7 @@ const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
|
||||
const MIN_ADDRESS_W = 120;
|
||||
/** Space between a mark and the tagline under a wordmark logo. */
|
||||
const WORDMARK_TAG_GAP = 3;
|
||||
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
|
||||
|
||||
/** The totals' labels read "Sub Total :", "CGST 9% :" like the design's "Tax 15% :". */
|
||||
export const labelWithColon = (label: string, rate?: string) => (rate ? `${label} ${rate} :` : `${label} :`);
|
||||
@@ -65,7 +65,7 @@ export function tableRows(model: RenderModel, v: CobaltVariant, rateW: number):
|
||||
const r = v.roles;
|
||||
const hsn = Slot.HsnSac(model);
|
||||
return model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, rateW, r.tableBody);
|
||||
const rate = rateCell(item, rateW, r.tableBody);
|
||||
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
|
||||
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
|
||||
return [String(i + 1), desc, item.qtyText, rateParts, item.amountText];
|
||||
@@ -245,10 +245,10 @@ export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVarian
|
||||
const t = v.table;
|
||||
const rule = t.separator;
|
||||
const widest = (role: TypeToken, texts: string[]) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, role, x))));
|
||||
const priced = model.items.map((i) => priceOf(i.rateText));
|
||||
const priced = model.items.map((i) => priceOf(i));
|
||||
const amountTexts = model.items.map((i) => i.amountText);
|
||||
const no = Math.max(t.cols.no, widest(r.tableBody, [String(model.items.length)]) + 12);
|
||||
const qty = Math.max(t.cols.qty, widest(r.tableBody, model.items.map((i) => i.qtyText)) + 12 + rule);
|
||||
const qty = Math.max(t.cols.qty, widest(r.tableBody, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody)) + 12 + rule);
|
||||
const priceNeed = Math.max(widest(r.tableBody, priced), widest(r.tableHead, ["Price"])) + 6 + rule + 6;
|
||||
const price = Math.max(t.cols.price, priceNeed);
|
||||
const priceInset = Math.max(6, Math.min(t.priceInset, price - rule - widest(r.tableBody, priced) - 4));
|
||||
|
||||
Reference in New Issue
Block a user