// DocumentPage — «página de documento» declarativa (benchmark §5.2 / §6.1): // cabecera + badges + barra de acciones por estado + botones inteligentes + // pestañas. Se alimenta de un `DocumentSpec` (datos puros) y de un registro; // las pestañas de campos y de registros relacionados se resuelven solas, y lo // que necesita UI del host (historial, archivos, PDF) entra por `slots`. import { useMemo, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@asteby/metacore-ui/primitives' import { cn } from '@asteby/metacore-ui/lib' import { ActionBar } from './action-bar' import { DocumentSecondaryBar } from './secondary-bar' import { DocumentHeader, type HeaderBadge, type HeaderMetric } from './document-header' import { SmartButtons, type SmartButtonItem } from './smart-buttons' import { StatusBadge } from './status-badge' import { interpolate, readPath, resolveActions, resolveStatus, resolveTitle } from './evaluate' import { formatFieldValue, type FieldFormatOptions } from './format' import { findStatusMachine, type DocumentPageRegistration } from './registry' import { useDocumentSources } from './use-document-sources' import type { DocumentColumnDef, DocumentContext, DocumentFieldDef, DocumentSlotRenderer, DocumentSpec, DocumentTabDef, ResolvedAction, } from './types' export interface DocumentPageProps extends FieldFormatOptions { registration: DocumentPageRegistration record: Record /** * Ejecuta una acción. El host decide: abrir el modal de la acción del * modelo (`def.modelAction ?? def.key`), navegar (`def.href`), etc. La * promesa mantiene el botón en `loading`. */ onAction: (action: ResolvedAction, ctx: DocumentContext) => void | Promise /** UI del host para pestañas `kind: 'slot'` (historial, archivos, vista previa PDF…). */ slots?: Record /** Navegación del host (botones inteligentes, cliente). */ onNavigate?: (href: string) => void renderLink?: (props: { href: string; children: ReactNode; className?: string }) => ReactNode /** Botón «Volver» u otro contenido antes del título. */ leading?: ReactNode /** Pestaña inicial (`?tab=`). */ defaultTab?: string /** Pestaña controlada por el host (p. ej. «Ver detalle del error» abre Datos fiscales). */ tab?: string onTabChange?: (tab: string) => void /** Ruta base para el enlace del subtítulo (`/m/customers/{{customer_id}}`). */ subtitleHrefTemplate?: string /** Cambia para forzar recarga de las fuentes tras una acción. */ reloadKey?: number className?: string } export function DocumentPage(props: DocumentPageProps) { const { registration, record, onAction, slots, onNavigate, renderLink, leading, defaultTab, className } = props const { spec } = registration const { t } = useTranslation() const fmt: FieldFormatOptions = { locale: props.locale, currency: props.currency, timeZone: props.timeZone } const recordId = record.id as string | number | undefined const { sources, totals, reload } = useDocumentSources(spec.sources, recordId) const ctx: DocumentContext = useMemo(() => { const base = { record, sources, totals } return { ...base, derived: registration.derive?.(base) ?? {} } }, [record, sources, totals, registration]) // Recarga las fuentes cuando el host avisa de un cambio (acción ejecutada). const [lastReload, setLastReload] = useState(props.reloadKey ?? 0) if ((props.reloadKey ?? 0) !== lastReload) { setLastReload(props.reloadKey ?? 0) reload() } const handleAction = (a: ResolvedAction) => { const { tab: targetTab, openUrl, href } = a.def if (targetTab) return setTab(targetTab) if (openUrl) { const url = interpolate(openUrl, ctx) if (url) window.open(url, '_blank', 'noopener,noreferrer') return } if (href) return onNavigate?.(interpolate(href, ctx)) return onAction(a, ctx) } const layout = useMemo(() => resolveActions(spec, ctx), [spec, ctx]) const badges: HeaderBadge[] = spec.statuses .map((m): HeaderBadge | null => { const status = resolveStatus(m, ctx) return status ? { id: m.id, label: m.label, status } : null }) .filter((b): b is HeaderBadge => !!b) const metrics: HeaderMetric[] = (spec.metrics ?? []) .filter((m) => !m.hideEmpty || readPath(ctx, m.path) != null) .map((m) => ({ label: t(m.label, { defaultValue: m.label }), value: formatFieldValue(readPath(ctx, m.path), m.format, fmt), tone: m.tone, mono: m.format === 'mono', })) const subtitleRaw = spec.subtitle ? readPath(ctx, spec.subtitle.path) : undefined const subtitleText = subtitleRaw == null || subtitleRaw === '' ? '—' : formatFieldValue(subtitleRaw, 'text', fmt) const subtitle = subtitleText === '—' ? undefined : subtitleText const subtitleHref = subtitle && props.subtitleHrefTemplate ? interpolate(props.subtitleHrefTemplate, ctx) : undefined const copyValue = spec.copyable ? readPath(ctx, spec.copyable.path) : undefined const [innerTab, setInnerTab] = useState(defaultTab ?? spec.tabs[0]?.key) const tab = props.tab ?? innerTab const setTab = (key: string) => { setInnerTab(key) props.onTabChange?.(key) } const smart: SmartButtonItem[] = (spec.smartButtons ?? []) .map((b): SmartButtonItem | null => { const count = b.source ? sourceCount(ctx, b.source) : undefined const active = b.source ? (count ?? 0) > 0 : b.field ? !!readPath(ctx, b.field) : true if (b.hideWhenEmpty && !active) return null const href = b.href ? interpolate(b.href, ctx) : undefined return { key: b.key, label: b.label, icon: b.icon, count, disabled: !active && !b.tab, onClick: () => { if (b.tab) setTab(b.tab) else if (href) onNavigate?.(href) }, } }) .filter((b): b is SmartButtonItem => !!b) return (
handleAction(a)} />} /> {spec.tabs.map((tb) => ( {t(tb.label, { defaultValue: tb.label })} {tabCount(tb, ctx) !== undefined ? ( {tabCount(tb, ctx)} ) : null} ))} {spec.tabs.map((tb) => ( {renderTab(tb, spec, ctx, fmt, slots)} ))} {/* Compartir / imprimir / correo / PDF-XML y lo que aporten los addons instalados: al pie, no compitiendo con la primaria. */} ({ key: a.def.key, label: a.def.label, icon: a.def.icon, blockedReason: a.blockedReason, run: () => handleAction(a), }))} model={spec.model} record={record} />
) } /** Total de una fuente: el del servidor si lo dio (`meta.total`), si no las filas traídas. */ function sourceCount(ctx: DocumentContext, key: string): number { return ctx.totals?.[key] ?? ctx.sources[key]?.length ?? 0 } function tabCount(tb: DocumentTabDef, ctx: DocumentContext): number | undefined { if (tb.kind === 'records') return ctx.sources[tb.source] ? sourceCount(ctx, tb.source) : undefined if (tb.kind === 'slot' && tb.count) { const v = readPath(ctx, tb.count) return typeof v === 'number' ? v : undefined } return undefined } function renderTab( tb: DocumentTabDef, spec: DocumentSpec, ctx: DocumentContext, fmt: FieldFormatOptions, slots: Record | undefined, ): ReactNode { if (tb.kind === 'slot') return slots?.[tb.slot]?.(ctx) ?? null if (tb.kind === 'fields') { return (
{tb.groups.map((g, i) => { const fields = g.fields.filter((f: DocumentFieldDef) => !f.hideEmpty || readPath(ctx, f.path) != null) if (fields.length === 0) return null return (
{g.title ?

{g.title}

: null}
{fields.map((f) => (
{f.label}
{formatFieldValue(readPath(ctx, f.path), f.format, fmt)}
))}
) })}
) } const rows = ctx.sources[tb.source] ?? [] if (rows.length === 0) { return (

{tb.empty ?? 'Sin registros.'}

) } return (
{tb.columns.map((c) => ( ))} {rows.map((row, i) => ( {tb.columns.map((c) => ( ))} ))}
{c.label}
{renderCell(c, row, spec, fmt)}
) } function renderCell(col: DocumentColumnDef, row: Record, spec: DocumentSpec, fmt: FieldFormatOptions): ReactNode { const v = row[col.path] if (col.status) { const machine = findStatusMachine(spec, col.status) const st = machine?.states.find((s) => s.value === String(v)) if (st) return } return ( {formatFieldValue(v, col.format, fmt)} ) }