import { Badge, type BadgeVariant } from "@tailor-platform/app-shell"; import { useMemo } from "react"; import { useT } from "@/i18n/labels"; import { EMPTY } from "@/lib/placeholder"; /** * Single source of truth for how a status enum is presented in a `` * across the IMS frontend. * * Why this exists: status values used to be rendered ad-hoc in every table — * each one defined its own inline `statusVariant` map, printed the raw * SCREAMING_SNAKE_CASE enum, and picked colours independently. The same status * (`DRAFT`, `CANCELLED`, …) ended up with different colours and casing * depending on the screen. See #690. * * The rules encoded here: * 1. **One canonical variant per status**, used identically in list tables and * detail cards. We standardise on the *solid* app-shell variants * (`success` / `warning` / `error` / `neutral`); the only exception is * informational / in-progress states, which use `outline-info` because * app-shell has no solid blue/info variant. * 2. **Title-case, human-readable labels** with domain acronyms preserved * (`PARTIALLY_RECEIVED` → "Partially Received", `NO_PO_FOUND` → "No PO Found"). * 3. **Only app-shell variants** — no bespoke colours or custom spans. */ export interface StatusPresentation { label: string; variant: BadgeVariant; } /** * Tokens that stay fully upper-cased when a raw enum value is humanised by * {@link toTitleCase} — domain acronyms that would otherwise read as "Po" / * "Gr" / "Ai". */ const ACRONYMS = new Set([ "PO", "GR", "GRN", "PI", "AI", "ASN", "SKU", "POS", "ID", "QBO", "FX", "UOM", ]); /** * Humanise a SCREAMING_SNAKE_CASE enum value into a Title Case label, * capitalising every word and preserving known acronyms. * * "PARTIALLY_RECEIVED" → "Partially Received" * "NO_PO_FOUND" → "No PO Found" * "in transit" → "In Transit" */ export function toTitleCase(value: string): string { return value .split(/[\s_]+/) .filter(Boolean) .map((word) => { const upper = word.toUpperCase(); if (ACRONYMS.has(upper)) return upper; return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); }) .join(" "); } /** * Canonical presentation for every status enum surfaced in the UI, keyed by * the raw enum value. Grouped by domain for readability, but a single flat map * so a given raw value renders identically wherever it appears. * * When a value spans domains with different meanings (e.g. `CLOSED`), we pick * the single most sensible reading — a closed order/transfer is a completed * terminal state, hence `success`. */ const STATUS_REGISTRY: Record = { // — Generic lifecycle: item / product / supplier / site / company / user / role — DRAFT: { label: "Draft", variant: "neutral" }, PENDING: { label: "Pending", variant: "warning" }, ACTIVE: { label: "Active", variant: "success" }, INACTIVE: { label: "Inactive", variant: "neutral" }, ARCHIVED: { label: "Archived", variant: "neutral" }, // — Purchase order: orderStatus — SUBMITTED: { label: "Submitted", variant: "warning" }, ORDERED: { label: "Ordered", variant: "success" }, CLOSED: { label: "Closed", variant: "success" }, CANCELLED: { label: "Cancelled", variant: "error" }, // — Purchase order: receiptStatus — NOT_RECEIVED: { label: "Not Received", variant: "neutral" }, PARTIALLY_RECEIVED: { label: "Partially Received", variant: "warning" }, RECEIVED: { label: "Received", variant: "success" }, // — Purchase order: billingStatus — NOT_BILLED: { label: "Not Billed", variant: "neutral" }, PARTIALLY_BILLED: { label: "Partially Billed", variant: "warning" }, BILLED: { label: "Billed", variant: "success" }, // — Inbound shipment — POSTED: { label: "Posted", variant: "success" }, // — Outgoing payment lifecycle (DRAFT/POSTED/CANCELLED shared above) — REVERSED: { label: "Reversed", variant: "neutral" }, // — Purchase bill — BLOCKED: { label: "Blocked", variant: "error" }, MATCHED: { label: "Matched", variant: "success" }, SETTLED: { label: "Settled", variant: "success" }, // — Shipment lifecycle — BOOKED: { label: "Booked", variant: "warning" }, IN_TRANSIT: { label: "In Transit", variant: "warning" }, RECEIVING: { label: "Receiving", variant: "warning" }, ARRIVED: { label: "Arrived", variant: "success" }, // — Transfer order — OPEN: { label: "Open", variant: "warning" }, // — Invoice reconciliation: unified displayStatus — PROCESSING: { label: "Processing", variant: "outline-info" }, FAILED: { label: "Failed", variant: "error" }, PARTIAL_MATCH: { label: "Partial Match", variant: "warning" }, MISMATCH: { label: "Mismatch", variant: "error" }, NO_PO_FOUND: { label: "No PO Found", variant: "error" }, NO_GR_FOUND: { label: "No GR Found", variant: "error" }, // — Invoice reconciliation: per-line status — QTY_MISMATCH: { label: "Quantity Mismatch", variant: "error" }, PRICE_MISMATCH: { label: "Price Mismatch", variant: "warning" }, NO_PO: { label: "No PO Found", variant: "error" }, NO_GR: { label: "No GR Found", variant: "error" }, // — Purchase bill provenance (`source`) — MANUAL: { label: "Manual", variant: "neutral" }, AI_RECONCILIATION: { label: "AI", variant: "outline-info" }, // — Origin channel (transfer order `source`) — TAILOR: { label: "Tailor", variant: "neutral" }, SHOPIFY_POS: { label: "Shopify POS", variant: "outline-info" }, // — General ledger: journalEntry.sourceDocumentType — ACCOUNT_PAYABLE_DOCUMENT: { label: "AP Document", variant: "neutral" }, OUTGOING_PAYMENT: { label: "Payment", variant: "neutral" }, SALES_INVOICE: { label: "Sales Invoice", variant: "neutral" }, INVENTORY_LEDGER: { label: "Inventory Ledger", variant: "neutral" }, STANDARD_COST_REVISION: { label: "Standard Cost Revision", variant: "neutral" }, PRODUCTION_ORDER: { label: "Production Order", variant: "neutral" }, YEAR_END_CLOSE: { label: "Year-End Close", variant: "neutral" }, }; /** Variant used for any value missing from the registry. */ const DEFAULT_VARIANT: BadgeVariant = "neutral"; /** * Resolve a raw status value to its canonical label + variant. Unknown values * fall back to a title-cased label and a neutral variant when callers go * through `resolveStatus` / ``. */ export function resolveStatus(value: string): StatusPresentation { return ( STATUS_REGISTRY[value] ?? { label: toTitleCase(value), variant: DEFAULT_VARIANT, } ); } /** * Canonical raw-value → variant map, for app-shell components that resolve * badges themselves (`DescriptionCard` / `DataTable` `type: "badge"` cells via * `meta.badgeVariantMap`). */ export const STATUS_VARIANTS: Record = Object.fromEntries( Object.entries(STATUS_REGISTRY).map(([key, { variant }]) => [key, variant]), ); /** Canonical raw-value → label map (pair with {@link STATUS_VARIANTS}). */ export const STATUS_LABELS: Record = Object.fromEntries( Object.entries(STATUS_REGISTRY).map(([key, { label }]) => [key, label]), ); /** * Localised copy of {@link STATUS_LABELS}: each registry key resolved through * `status.` (English fallback for untranslated statuses). Memoised per * locale via a representative resolved label so the map keeps a stable identity * between renders — callers spread it into a DataTable / DescriptionCard * `badgeLabelMap`, and a churning map would rebuild their memoised columns / * fields on every render. */ function useLocalizedStatusLabels(): Record { const t = useT(); const localeSignature = t.dynamic("status.POSTED", STATUS_LABELS.POSTED); return useMemo( () => Object.fromEntries( Object.entries(STATUS_LABELS).map(([value, en]) => [ value, t.dynamic(`status.${value}`, en), ]), ), // `t`'s identity changes every render, so key the memo on the locale // signature instead — the labels only change when the locale changes. // eslint-disable-next-line react-hooks/exhaustive-deps [localeSignature], ); } /** * Hook form of {@link statusBadgeMeta} that localises the badge labels for the * active locale. Use inside a component: `meta: useStatusBadgeMeta()`. */ export function useStatusBadgeMeta() { const badgeLabelMap = useLocalizedStatusLabels(); return { badgeVariantMap: STATUS_VARIANTS, badgeLabelMap, defaultBadgeVariant: DEFAULT_VARIANT, sentenceCaseBadges: false, } as const; } /** * Hook form of {@link statusBadgeCellOptions} that localises the badge labels * for the active locale. Use inside a component: * `typeOptions: useStatusBadgeCellOptions()`. */ export function useStatusBadgeCellOptions() { const badgeLabelMap = useLocalizedStatusLabels(); return { badgeVariantMap: STATUS_VARIANTS, badgeLabelMap, defaultBadgeVariant: DEFAULT_VARIANT, } as const; } export interface StatusBadgeProps { /** Raw status enum value (e.g. `"PARTIALLY_RECEIVED"`). */ value: string | null | undefined; className?: string; } /** * Canonical status badge. Renders the registered label + variant for `value`, * or a neutral dash for an empty value. This is the single component every * hand-rendered status badge in the app should use. */ export function StatusBadge({ value, className }: StatusBadgeProps) { const t = useT(); if (!value) { return ( {EMPTY} ); } const { label, variant } = resolveStatus(value); // Localize via `status.`, falling back to the registry's English label // for any status not yet translated. return ( {t.dynamic(`status.${value}`, label)} ); }