// OptionDisplay — paints the declarative option display the kernel resolves for // every option of a model with `option_display` (manifest v3): avatar, title on // up to two lines, muted subtitle, badges, and right-aligned metrics (price, // stock…) with tabular numbers and a toned chip («Agotado» in danger, low // stock in warning). The server already evaluated formats' inputs, tones, // dim/block — this file only formats and paints, so every picker of the model // (dynamic_select, the document editor's product cell, an addon screen) reads // the same. // // It plugs into through its existing slots (`renderItem`, // `renderValue`, `isItemDisabled`), never by editing the picker itself. An // option without `display` keeps rendering exactly as before. import { useTranslation } from 'react-i18next' import { Badge } from '@asteby/metacore-ui/primitives' import { useFormatter, type Formatter } from './business/format' import { OptionThumb } from './record-picker-option' export type OptionDisplayTone = 'success' | 'warning' | 'danger' | 'info' | 'neutral' /** One resolved right-aligned metric (`display.trailing[]`). */ export interface OptionTrailingItem { key: string label?: string value: unknown format?: 'money' | 'number' | 'integer' | 'percent' | 'date' | 'relative_date' | 'text' | string currency?: string tone?: OptionDisplayTone | string /** Replaces the formatted value («Agotado»). */ text?: string } export interface OptionBadgeItem { text: string tone?: OptionDisplayTone | string } /** The `display` object of an option (kernel `OptionDisplayValue`). */ export interface OptionDisplayData { title?: string subtitle?: string image?: string | null trailing?: OptionTrailingItem[] badges?: OptionBadgeItem[] tone?: OptionDisplayTone | string /** Attenuated row (still selectable). */ dimmed?: boolean /** Not selectable. */ blocked?: boolean } /** Reads a well-formed display off any option-like value (null when absent). */ export function getOptionDisplay(option: unknown): OptionDisplayData | null { const d = option && typeof option === 'object' ? (option as { display?: unknown }).display : null return d && typeof d === 'object' ? (d as OptionDisplayData) : null } type BadgeVariant = 'success' | 'warning' | 'danger' | 'info' | 'muted' const TONE_VARIANT: Record = { success: 'success', warning: 'warning', danger: 'danger', info: 'info', neutral: 'muted', } export const toneVariant = (tone?: string): BadgeVariant => TONE_VARIANT[tone ?? ''] ?? 'muted' const asNumber = (v: unknown): number | null => { if (typeof v === 'number') return Number.isFinite(v) ? v : null if (typeof v === 'string' && v.trim() !== '') { const n = Number(v) return Number.isFinite(n) ? n : null } return null } const RELATIVE_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [ ['year', 365 * 86400], ['month', 30 * 86400], ['week', 7 * 86400], ['day', 86400], ['hour', 3600], ['minute', 60], ] /** «hace 3 días» / «en 2 semanas» in the formatter's locale. */ export function formatRelativeDate(input: unknown, locale: string, now: Date = new Date()): string { if (input == null || input === '') return '' const d = new Date(input as string) if (Number.isNaN(d.getTime())) return String(input) const secs = (d.getTime() - now.getTime()) / 1000 const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) for (const [unit, size] of RELATIVE_UNITS) { if (Math.abs(secs) >= size) return rtf.format(Math.round(secs / size), unit) } return rtf.format(0, 'minute') } /** Formats one trailing value per its declared format (org currency for money). */ export function formatTrailingValue(item: OptionTrailingItem, fmt: Formatter, now?: Date): string { const v = item.value if (v == null || v === '') return '' switch (item.format) { case 'money': return fmt.money(v, item.currency || undefined) case 'number': return fmt.quantity(v) case 'integer': { const n = asNumber(v) return n == null ? String(v) : new Intl.NumberFormat(fmt.locale, { maximumFractionDigits: 0 }).format(n) } case 'percent': { const n = asNumber(v) if (n == null) return String(v) // 0.16 and 16 both mean 16 %. const frac = Math.abs(n) <= 1 ? n : n / 100 return new Intl.NumberFormat(fmt.locale, { style: 'percent', maximumFractionDigits: 1 }).format(frac) } case 'date': return fmt.date(v) case 'relative_date': return formatRelativeDate(v, fmt.locale, now) default: return typeof v === 'number' ? fmt.quantity(v) : String(v) } } /** * One metric. A toned metric is a chip carrying its value and label («18 * disp.», or the tone's text «Agotado»); a plain one is the value in tabular * numbers, with its label inline only when `showLabel` (several plain metrics * side by side need it to tell «Saldo» from «Vencido»; a lone price does not). */ function TrailingMetric({ item, fmt, showLabel }: { item: OptionTrailingItem; fmt: Formatter; showLabel?: boolean }) { const { t } = useTranslation() const label = item.label ? t(item.label, { defaultValue: item.label }) : '' const value = formatTrailingValue(item, fmt) if (item.tone) { const text = item.text ? t(item.text, { defaultValue: item.text }) : [value, label].filter(Boolean).join(' ') return ( {text} ) } return ( {showLabel && label ? {label} : null} {item.text ? t(item.text, { defaultValue: item.text }) : value} ) } export interface OptionDisplayRowProps { display: OptionDisplayData /** Fallback title (the option label) when the display has none. */ label?: string /** Keyboard / pointer highlight (for the title color). */ active?: boolean selected?: boolean /** Org currency override for money metrics without their own currency. */ currency?: string /** Avatar size in px. Default 32. */ avatarSize?: number } /** * The option row: avatar 32px · title (≤2 lines) + badges · muted subtitle · * metrics aligned right. `dimmed` attenuates the content, never the hover. */ export function OptionDisplayRow({ display, label, active, selected, currency, avatarSize = 32 }: OptionDisplayRowProps) { const { t } = useTranslation() const fmt = useFormatter({ currency }) const title = display.title || label || '' const trailing = display.trailing ?? [] const badges = display.badges ?? [] const plainCount = trailing.filter((i) => !i.tone).length return ( {title} {badges.map((b, i) => ( {t(b.text, { defaultValue: b.text })} ))} {display.subtitle ? ( {display.subtitle} ) : null} {trailing.length > 0 ? ( // Metrics stack right-aligned (price over the stock chip): the // column stays narrow so the title keeps its room, and the // values line up across rows. {trailing.map((item) => ( 1} /> ))} ) : null} ) } /** * Compact summary for a picker's trigger: small avatar, title and the most * relevant metric (the toned one, else the first). */ export function OptionDisplayValue({ display, label, currency }: { display: OptionDisplayData; label?: string; currency?: string }) { const fmt = useFormatter({ currency }) const title = display.title || label || '' const trailing = display.trailing ?? [] const lead = trailing.find((i) => i.tone) ?? trailing[0] const extra = lead && trailing.find((i) => i !== lead && !i.tone) return ( {title} {extra ? ( {formatTrailingValue(extra, fmt)} ) : null} {lead ? : null} ) } /** Whether any of the options carries a display (switches a picker to rich rows). */ export const hasOptionDisplays = (options: readonly unknown[]): boolean => options.some((o) => getOptionDisplay(o) !== null)