dodil/erp-ui

src/lib/format.ts

/**
 * Formatting for ERP values — money above all.
 *
 * Money arrives from every brick as an exact decimal STRING ("1250.50"), because JSON has only doubles and
 * a float total is a support ticket a quarter later. It is formatted here as text — thousands separators
 * inserted, never `Number(...)` — so what a person reads is exactly what the ledger holds. A zero may arrive
 * as the JSON number 0 (DataK3 renders a zero DECIMAL that way); it is normalised, never computed with.
 */

export type MoneyLike = string | number | null | undefined;

/** "1234567.5" → "1,234,567.50"; never via a float. Negative keeps its sign. Invalid input is shown as-is. */
export function formatAmount(amount: MoneyLike, decimals = 2): string {
  if (amount === null || amount === undefined || amount === "") return "—";
  const text = typeof amount === "number" ? String(amount) : amount.trim();
  const m = /^(-)?(\d+)(?:\.(\d+))?$/.exec(text);
  if (!m) return text;
  const [, sign, whole, frac = ""] = m;
  const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  const fraction = (frac + "0".repeat(decimals)).slice(0, decimals);
  return `${sign ?? ""}${grouped}${decimals ? "." + fraction : ""}`;
}

export function formatMoney(amount: MoneyLike, currency?: string | null): string {
  const a = formatAmount(amount);
  return a === "—" || !currency ? a : `${a} ${currency}`;
}

/** Compare two decimal strings exactly (for sorting money): -1, 0 or 1. Non-numbers sort last. */
export function compareDecimal(a: MoneyLike, b: MoneyLike): number {
  const pa = parse(a), pb = parse(b);
  if (!pa && !pb) return 0;
  if (!pa) return 1;
  if (!pb) return -1;
  if (pa.neg !== pb.neg) return pa.neg ? -1 : 1;
  const mag = compareMagnitude(pa, pb);
  return pa.neg ? -mag : mag;
}

function parse(v: MoneyLike) {
  if (v === null || v === undefined) return null;
  const m = /^(-)?0*(\d*?)(?:\.(\d*?)0*)?$/.exec(String(v).trim());
  if (!m || String(v).trim() === "" || String(v).trim() === "-") return null;
  const whole = m[2] || "0", frac = m[3] ?? "";
  const zero = whole === "0" && /^0*$/.test(frac);
  return { neg: !!m[1] && !zero, whole, frac };
}

function compareMagnitude(a: { whole: string; frac: string }, b: { whole: string; frac: string }): number {
  if (a.whole.length !== b.whole.length) return a.whole.length < b.whole.length ? -1 : 1;
  if (a.whole !== b.whole) return a.whole < b.whole ? -1 : 1;
  const n = Math.max(a.frac.length, b.frac.length);
  const fa = a.frac.padEnd(n, "0"), fb = b.frac.padEnd(n, "0");
  return fa === fb ? 0 : fa < fb ? -1 : 1;
}

/**
 * Exact decimal addition, as text: "0.10" + "0.20" is "0.3" (show it with formatAmount: "0.30"), and
 * 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
 * forecast's total) — never to compute a price, which is the server's job.
 */
export function addDecimal(a: MoneyLike, b: MoneyLike): string {
  const pa = parse(a ?? "0") ?? parse("0")!, pb = parse(b ?? "0") ?? parse("0")!;
  const scale = Math.max(pa.frac.length, pb.frac.length);
  const big = (p: { neg: boolean; whole: string; frac: string }) =>
    (p.neg ? -1n : 1n) * BigInt(p.whole + p.frac.padEnd(scale, "0"));
  const sum = big(pa) + big(pb);
  const neg = sum < 0n;
  const digits = (neg ? -sum : sum).toString().padStart(scale + 1, "0");
  const whole = digits.slice(0, digits.length - scale), frac = digits.slice(digits.length - scale);
  return `${neg ? "-" : ""}${whole}${scale ? "." + frac : ""}`;
}

/** The exact sum of a list of decimals; "0" for none. */
export function sumDecimal(values: MoneyLike[]): string {
  return values.reduce<string>((acc, v) => addDecimal(acc, v), "0");
}

/** "2026-10-16" or an ISO time → "16 Oct 2026" (the reader's locale decides the words, not the date). */
export function formatDate(iso: string | null | undefined, locale?: string): string {
  if (!iso) return "—";
  const d = new Date(iso.length === 10 ? `${iso}T00:00:00Z` : iso);
  if (Number.isNaN(d.getTime())) return iso;
  return d.toLocaleDateString(locale, { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" });
}

export function formatDateTime(iso: string | null | undefined, locale?: string): string {
  if (!iso) return "—";
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return iso;
  return d.toLocaleString(locale, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
}