dodil/erp-ui
public/r/format.json
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "format",
"title": "ERP formatting",
"description": "Money as exact text (never a float), exact decimal comparison and addition, dates.",
"files": [
{
"path": "src/lib/format.ts",
"content": "/**\n * Formatting for ERP values — money above all.\n *\n * Money arrives from every brick as an exact decimal STRING (\"1250.50\"), because JSON has only doubles and\n * a float total is a support ticket a quarter later. It is formatted here as text — thousands separators\n * inserted, never `Number(...)` — so what a person reads is exactly what the ledger holds. A zero may arrive\n * as the JSON number 0 (DataK3 renders a zero DECIMAL that way); it is normalised, never computed with.\n */\n\nexport type MoneyLike = string | number | null | undefined;\n\n/** \"1234567.5\" → \"1,234,567.50\"; never via a float. Negative keeps its sign. Invalid input is shown as-is. */\nexport function formatAmount(amount: MoneyLike, decimals = 2): string {\n if (amount === null || amount === undefined || amount === \"\") return \"—\";\n const text = typeof amount === \"number\" ? String(amount) : amount.trim();\n const m = /^(-)?(\\d+)(?:\\.(\\d+))?$/.exec(text);\n if (!m) return text;\n const [, sign, whole, frac = \"\"] = m;\n const grouped = whole.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n const fraction = (frac + \"0\".repeat(decimals)).slice(0, decimals);\n return `${sign ?? \"\"}${grouped}${decimals ? \".\" + fraction : \"\"}`;\n}\n\nexport function formatMoney(amount: MoneyLike, currency?: string | null): string {\n const a = formatAmount(amount);\n return a === \"—\" || !currency ? a : `${a} ${currency}`;\n}\n\n/** Compare two decimal strings exactly (for sorting money): -1, 0 or 1. Non-numbers sort last. */\nexport function compareDecimal(a: MoneyLike, b: MoneyLike): number {\n const pa = parse(a), pb = parse(b);\n if (!pa && !pb) return 0;\n if (!pa) return 1;\n if (!pb) return -1;\n if (pa.neg !== pb.neg) return pa.neg ? -1 : 1;\n const mag = compareMagnitude(pa, pb);\n return pa.neg ? -mag : mag;\n}\n\nfunction parse(v: MoneyLike) {\n if (v === null || v === undefined) return null;\n const m = /^(-)?0*(\\d*?)(?:\\.(\\d*?)0*)?$/.exec(String(v).trim());\n if (!m || String(v).trim() === \"\" || String(v).trim() === \"-\") return null;\n const whole = m[2] || \"0\", frac = m[3] ?? \"\";\n const zero = whole === \"0\" && /^0*$/.test(frac);\n return { neg: !!m[1] && !zero, whole, frac };\n}\n\nfunction compareMagnitude(a: { whole: string; frac: string }, b: { whole: string; frac: string }): number {\n if (a.whole.length !== b.whole.length) return a.whole.length < b.whole.length ? -1 : 1;\n if (a.whole !== b.whole) return a.whole < b.whole ? -1 : 1;\n const n = Math.max(a.frac.length, b.frac.length);\n const fa = a.frac.padEnd(n, \"0\"), fb = b.frac.padEnd(n, \"0\");\n return fa === fb ? 0 : fa < fb ? -1 : 1;\n}\n\n/**\n * Exact decimal addition, as text: \"0.10\" + \"0.20\" is \"0.3\" (show it with formatAmount: \"0.30\"), and\n * amounts past a float's precision add up to the cent. For totals a screen shows beside rows the API already computed (a pipeline column, a\n * forecast's total) — never to compute a price, which is the server's job.\n */\nexport function addDecimal(a: MoneyLike, b: MoneyLike): string {\n const pa = parse(a ?? \"0\") ?? parse(\"0\")!, pb = parse(b ?? \"0\") ?? parse(\"0\")!;\n const scale = Math.max(pa.frac.length, pb.frac.length);\n const big = (p: { neg: boolean; whole: string; frac: string }) =>\n (p.neg ? -1n : 1n) * BigInt(p.whole + p.frac.padEnd(scale, \"0\"));\n const sum = big(pa) + big(pb);\n const neg = sum < 0n;\n const digits = (neg ? -sum : sum).toString().padStart(scale + 1, \"0\");\n const whole = digits.slice(0, digits.length - scale), frac = digits.slice(digits.length - scale);\n return `${neg ? \"-\" : \"\"}${whole}${scale ? \".\" + frac : \"\"}`;\n}\n\n/** The exact sum of a list of decimals; \"0\" for none. */\nexport function sumDecimal(values: MoneyLike[]): string {\n return values.reduce<string>((acc, v) => addDecimal(acc, v), \"0\");\n}\n\n/** \"2026-10-16\" or an ISO time → \"16 Oct 2026\" (the reader's locale decides the words, not the date). */\nexport function formatDate(iso: string | null | undefined, locale?: string): string {\n if (!iso) return \"—\";\n const d = new Date(iso.length === 10 ? `${iso}T00:00:00Z` : iso);\n if (Number.isNaN(d.getTime())) return iso;\n return d.toLocaleDateString(locale, { day: \"numeric\", month: \"short\", year: \"numeric\", timeZone: \"UTC\" });\n}\n\nexport function formatDateTime(iso: string | null | undefined, locale?: string): string {\n if (!iso) return \"—\";\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return iso;\n return d.toLocaleString(locale, { day: \"numeric\", month: \"short\", year: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" });\n}\n",
"type": "registry:lib",
"target": "erp-ui/lib/format.ts"
}
],
"type": "registry:lib"
}