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:
+27
-17
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user