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:
2026-10-06 09:35:20 +05:30
parent cd067ad584
commit 3aa0d33a01
52 changed files with 910 additions and 148 deletions
+27 -17
View File
@@ -34,7 +34,8 @@ import {
} from "@carbon/icons-react";
import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
import { EMPTY_ITEM, INDIAN_STATES } from "../lib/types";
import { isValidUnit } from "../lib/units";
import type {
BankAccount,
Client,
@@ -44,7 +45,6 @@ import type {
InvoiceInput,
InvoiceItem,
LineMode,
LineUnit,
Settings,
TaxType,
} from "../lib/types";
@@ -57,6 +57,8 @@ import { formToPreviewInvoice } from "../lib/previewInvoice";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
import PageSetupControls from "../components/PageSetupControls";
import QuantityInput from "../components/QuantityInput";
import UnitPicker from "../components/UnitPicker";
import { useInvoicePdf } from "../hooks/useInvoicePdf";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { autoPushIssued } from "../lib/erpnextUi";
@@ -257,6 +259,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
? "cgst_sgst"
: "igst";
const clientGstinError = gstinFullError(clientGstin);
// Quantity boxes that hold text which is not a quantity; they keep the last good value, so issuing must wait.
const badQuantities = useRef(new Set<string>());
const markQuantity = (id: string, ok: boolean) => {
if (ok) badQuantities.current.delete(id);
else badQuantities.current.add(id);
};
const totals = useMemo(
() => computeTotals(items, discount, effectiveTaxType, taxRate),
[items, discount, effectiveTaxType, taxRate],
@@ -614,6 +622,14 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setError(`Client GSTIN: ${clientGstinError}`);
return;
}
if (badQuantities.current.size > 0) {
setError("A quantity is not a number. Fix the line items marked in red.");
return;
}
if (items.some((i) => i.mode === "rate" && !isValidUnit(i.unit))) {
setError("A line item has no valid unit. Pick one, or type a custom unit.");
return;
}
setSaving(true);
setError(null);
exp.dismiss();
@@ -997,28 +1013,22 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
onChange={(_e, { value }) => updateItem(index, { rate: Number(value) || 0 })}
/>
</div>
<div style={{ flex: "0 1 140px" }}>
<Select
<div style={{ flex: "0 1 160px" }}>
<UnitPicker
id={`unit-${index}`}
disabled={locked}
labelText="Unit"
value={item.unit}
onChange={(e) => updateItem(index, { unit: e.target.value as LineUnit })}
>
{Object.entries(UNIT_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
onChange={(unit) => updateItem(index, { unit })}
/>
</div>
<div style={{ flex: "0 1 110px" }}>
<NumberInput
<div style={{ flex: "0 1 130px" }}>
<QuantityInput
id={`qty-${index}`}
disabled={locked}
label="Quantity"
min={0}
step={1}
value={item.quantity}
onChange={(_e, { value }) => updateItem(index, { quantity: Number(value) || 0 })}
unit={item.unit}
onChange={(quantity) => updateItem(index, { quantity })}
onValidity={(ok) => markQuantity(`qty-${index}`, ok)}
/>
</div>
</>