// The single place the UI resolves and renders units of measure. // // Before this module, ~10 screens each re-declared their own inline `uoms.find(...)` under // three different names, with two different unknown-UOM fallbacks (`#12` vs `UOM 12`), and // there was no quantity formatter anywhere. Everything unit-shaped goes through here now. // // The one rule these helpers encode: **conversion toward base is authoritative.** The server // stores stock, layers and the ledger exclusively in an item's base UOM, and resolves the // base quantity when a document line is saved. `toBase` below exists to *preview* that for // the user at entry time; it is never the source of what gets posted. import { AllowedUom, Uom } from "@/types/master-data" /** Shown when a uomId has no matching row — one fallback across the whole app. */ const UNKNOWN_UOM = "—" /** Quantities are `decimal(18,4)` server-side; trailing zeros are noise in a table. */ const QTY_FORMATTER = new Intl.NumberFormat("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 4, }) /** `12` -> "PCS". Accepts any list with `uomId`/`name`, so `Uom[]` and `AllowedUom[]` both work. */ export function uomName( uomId: number | null | undefined, uoms: readonly { uomId: number; name: string }[], ): string { if (uomId === null || uomId === undefined || uomId === 0) return UNKNOWN_UOM return uoms.find((u) => u.uomId === uomId)?.name ?? UNKNOWN_UOM } /** `1234.5` -> "1,234.5". Quantity-specific: unlike `formatAmount` it does not force 2dp. */ export function formatQtyValue(qty: number | null | undefined): string { if (qty === null || qty === undefined || Number.isNaN(qty)) return "—" return QTY_FORMATTER.format(qty) } /** * `(24, 12, uoms)` -> "24 PCS". The formatter every screen showing a quantity should use — * a bare number leaves the user guessing which unit a figure is in. */ export function formatQty( qty: number | null | undefined, uomId: number | null | undefined, uoms: readonly { uomId: number; name: string }[], ): string { const value = formatQtyValue(qty) const unit = uomName(uomId, uoms) return unit === UNKNOWN_UOM ? value : `${value} ${unit}` } /** Convenience for stock screens, whose DTOs carry `baseUomName` directly from the server. */ export function formatQtyWithName(qty: number | null | undefined, uomName: string | null | undefined): string { const value = formatQtyValue(qty) return uomName ? `${value} ${uomName}` : value } /** * Converts an entered quantity to the item's base UOM, matching the server's arithmetic * (multiply by the factor, round to 4dp). Display only — the authoritative base quantity is * the one the server resolves and snapshots when the line is saved. */ export function toBase(qty: number, factor: number): number { if (!Number.isFinite(qty) || !Number.isFinite(factor)) return 0 return Math.round(qty * factor * 10_000) / 10_000 } /** * The hint rendered beside a quantity input: `"= 24 PCS"` when the chosen unit is not the * item's base, and `null` when it is (a "= 24 PCS" next to "24 PCS" is just noise). * * Making the conversion visible at entry is the point — previously a user only discovered a * unit mismatch as a 422 when they tried to post the finished document. */ export function basePreview( qty: number | null | undefined, uomId: number | null | undefined, allowed: readonly AllowedUom[], ): string | null { if (!qty || !uomId) return null const selected = allowed.find((u) => u.uomId === uomId) const base = allowed.find((u) => u.isBase) if (!selected || !base || selected.isBase) return null return `= ${formatQtyValue(toBase(qty, selected.factor))} ${base.name}` } /** * Falls back to the global UOM list while an item's allowed units are still loading (or when * no item is chosen yet), so a picker never renders empty. Once `allowed` arrives it wins — * that narrowing is the whole reason the endpoint exists. */ export function pickerOptions(allowed: readonly AllowedUom[] | undefined, all: readonly Uom[]): { uomId: number; name: string }[] { if (allowed && allowed.length > 0) return allowed.map((u) => ({ uomId: u.uomId, name: u.name })) return all.map((u) => ({ uomId: u.uomId, name: u.name })) }