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"
}