@asteby/metacore-runtime-react 45.0.0 → 45.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/approval-events.d.ts +16 -0
  3. package/dist/approval-events.d.ts.map +1 -0
  4. package/dist/approval-events.js +27 -0
  5. package/dist/approval-gate.d.ts +39 -0
  6. package/dist/approval-gate.d.ts.map +1 -0
  7. package/dist/approval-gate.js +110 -0
  8. package/dist/approval-pin-dialog.d.ts +25 -0
  9. package/dist/approval-pin-dialog.d.ts.map +1 -0
  10. package/dist/approval-pin-dialog.js +66 -0
  11. package/dist/business/catalog.d.ts +16 -0
  12. package/dist/business/catalog.d.ts.map +1 -0
  13. package/dist/business/catalog.js +42 -0
  14. package/dist/business/customer-picker.d.ts +39 -0
  15. package/dist/business/customer-picker.d.ts.map +1 -0
  16. package/dist/business/customer-picker.js +51 -0
  17. package/dist/business/feedback.d.ts +72 -0
  18. package/dist/business/feedback.d.ts.map +1 -0
  19. package/dist/business/feedback.js +52 -0
  20. package/dist/business/field-errors.d.ts +26 -0
  21. package/dist/business/field-errors.d.ts.map +1 -0
  22. package/dist/business/field-errors.js +59 -0
  23. package/dist/business/format.d.ts +49 -0
  24. package/dist/business/format.d.ts.map +1 -0
  25. package/dist/business/format.js +128 -0
  26. package/dist/business/index.d.ts +14 -0
  27. package/dist/business/index.d.ts.map +1 -0
  28. package/dist/business/index.js +13 -0
  29. package/dist/business/line-items-editor.d.ts +25 -0
  30. package/dist/business/line-items-editor.d.ts.map +1 -0
  31. package/dist/business/line-items-editor.js +36 -0
  32. package/dist/business/line-items.d.ts +69 -0
  33. package/dist/business/line-items.d.ts.map +1 -0
  34. package/dist/business/line-items.js +133 -0
  35. package/dist/business/payment-capture.d.ts +21 -0
  36. package/dist/business/payment-capture.d.ts.map +1 -0
  37. package/dist/business/payment-capture.js +24 -0
  38. package/dist/business/payment.d.ts +61 -0
  39. package/dist/business/payment.d.ts.map +1 -0
  40. package/dist/business/payment.js +74 -0
  41. package/dist/business/product-picker.d.ts +24 -0
  42. package/dist/business/product-picker.d.ts.map +1 -0
  43. package/dist/business/product-picker.js +48 -0
  44. package/dist/business/product-search.d.ts +70 -0
  45. package/dist/business/product-search.d.ts.map +1 -0
  46. package/dist/business/product-search.js +51 -0
  47. package/dist/business/refund-destination.d.ts +19 -0
  48. package/dist/business/refund-destination.d.ts.map +1 -0
  49. package/dist/business/refund-destination.js +31 -0
  50. package/dist/business/refund.d.ts +29 -0
  51. package/dist/business/refund.d.ts.map +1 -0
  52. package/dist/business/refund.js +43 -0
  53. package/dist/business/use-async-search.d.ts +10 -0
  54. package/dist/business/use-async-search.d.ts.map +1 -0
  55. package/dist/business/use-async-search.js +38 -0
  56. package/dist/document/action-bar.d.ts +12 -0
  57. package/dist/document/action-bar.d.ts.map +1 -0
  58. package/dist/document/action-bar.js +39 -0
  59. package/dist/document/cancel-with-reason.d.ts +47 -0
  60. package/dist/document/cancel-with-reason.d.ts.map +1 -0
  61. package/dist/document/cancel-with-reason.js +52 -0
  62. package/dist/document/document-header.d.ts +41 -0
  63. package/dist/document/document-header.d.ts.map +1 -0
  64. package/dist/document/document-header.js +30 -0
  65. package/dist/document/document-page.d.ts +37 -0
  66. package/dist/document/document-page.d.ts.map +1 -0
  67. package/dist/document/document-page.js +137 -0
  68. package/dist/document/document-timeline.d.ts +20 -0
  69. package/dist/document/document-timeline.d.ts.map +1 -0
  70. package/dist/document/document-timeline.js +26 -0
  71. package/dist/document/evaluate.d.ts +31 -0
  72. package/dist/document/evaluate.d.ts.map +1 -0
  73. package/dist/document/evaluate.js +170 -0
  74. package/dist/document/format.d.ts +9 -0
  75. package/dist/document/format.d.ts.map +1 -0
  76. package/dist/document/format.js +52 -0
  77. package/dist/document/index.d.ts +13 -0
  78. package/dist/document/index.d.ts.map +1 -0
  79. package/dist/document/index.js +12 -0
  80. package/dist/document/registry.d.ts +20 -0
  81. package/dist/document/registry.d.ts.map +1 -0
  82. package/dist/document/registry.js +20 -0
  83. package/dist/document/smart-buttons.d.ts +16 -0
  84. package/dist/document/smart-buttons.d.ts.map +1 -0
  85. package/dist/document/smart-buttons.js +16 -0
  86. package/dist/document/status-badge.d.ts +16 -0
  87. package/dist/document/status-badge.d.ts.map +1 -0
  88. package/dist/document/status-badge.js +23 -0
  89. package/dist/document/types.d.ts +196 -0
  90. package/dist/document/types.d.ts.map +1 -0
  91. package/dist/document/types.js +1 -0
  92. package/dist/document/use-document-sources.d.ts +14 -0
  93. package/dist/document/use-document-sources.d.ts.map +1 -0
  94. package/dist/document/use-document-sources.js +72 -0
  95. package/dist/index.d.ts +4 -0
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +5 -0
  98. package/dist/server-error.d.ts.map +1 -1
  99. package/dist/server-error.js +19 -0
  100. package/package.json +1 -1
  101. package/src/__tests__/approval-gate.test.tsx +134 -0
  102. package/src/approval-events.ts +39 -0
  103. package/src/approval-gate.tsx +180 -0
  104. package/src/approval-pin-dialog.tsx +162 -0
  105. package/src/business/business.test.ts +164 -0
  106. package/src/business/catalog.ts +60 -0
  107. package/src/business/customer-picker.tsx +201 -0
  108. package/src/business/feedback.tsx +141 -0
  109. package/src/business/field-errors.ts +92 -0
  110. package/src/business/format.ts +151 -0
  111. package/src/business/index.ts +13 -0
  112. package/src/business/line-items-editor.tsx +259 -0
  113. package/src/business/line-items.ts +190 -0
  114. package/src/business/payment-capture.tsx +158 -0
  115. package/src/business/payment.ts +125 -0
  116. package/src/business/product-picker.tsx +171 -0
  117. package/src/business/product-search.ts +108 -0
  118. package/src/business/refund-destination.tsx +121 -0
  119. package/src/business/refund.ts +71 -0
  120. package/src/business/use-async-search.ts +43 -0
  121. package/src/document/__tests__/document-page.test.tsx +107 -0
  122. package/src/document/__tests__/evaluate.test.ts +140 -0
  123. package/src/document/action-bar.tsx +140 -0
  124. package/src/document/cancel-with-reason.tsx +214 -0
  125. package/src/document/document-header.tsx +140 -0
  126. package/src/document/document-page.tsx +268 -0
  127. package/src/document/document-timeline.tsx +66 -0
  128. package/src/document/evaluate.ts +180 -0
  129. package/src/document/format.ts +59 -0
  130. package/src/document/index.ts +28 -0
  131. package/src/document/registry.ts +39 -0
  132. package/src/document/smart-buttons.tsx +54 -0
  133. package/src/document/status-badge.tsx +44 -0
  134. package/src/document/types.ts +203 -0
  135. package/src/document/use-document-sources.ts +91 -0
  136. package/src/index.ts +23 -0
  137. package/src/server-error.ts +20 -0
@@ -0,0 +1,268 @@
1
+ // DocumentPage — «página de documento» declarativa (benchmark §5.2 / §6.1):
2
+ // cabecera + badges + barra de acciones por estado + botones inteligentes +
3
+ // pestañas. Se alimenta de un `DocumentSpec` (datos puros) y de un registro;
4
+ // las pestañas de campos y de registros relacionados se resuelven solas, y lo
5
+ // que necesita UI del host (historial, archivos, PDF) entra por `slots`.
6
+ import { useMemo, useState, type ReactNode } from 'react'
7
+ import { useTranslation } from 'react-i18next'
8
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@asteby/metacore-ui/primitives'
9
+ import { cn } from '@asteby/metacore-ui/lib'
10
+ import { ActionBar } from './action-bar'
11
+ import { DocumentHeader, type HeaderBadge, type HeaderMetric } from './document-header'
12
+ import { SmartButtons, type SmartButtonItem } from './smart-buttons'
13
+ import { StatusBadge } from './status-badge'
14
+ import { interpolate, readPath, resolveActions, resolveStatus, resolveTitle } from './evaluate'
15
+ import { formatFieldValue, type FieldFormatOptions } from './format'
16
+ import { findStatusMachine, type DocumentPageRegistration } from './registry'
17
+ import { useDocumentSources } from './use-document-sources'
18
+ import type {
19
+ DocumentColumnDef,
20
+ DocumentContext,
21
+ DocumentFieldDef,
22
+ DocumentSlotRenderer,
23
+ DocumentSpec,
24
+ DocumentTabDef,
25
+ ResolvedAction,
26
+ } from './types'
27
+
28
+ export interface DocumentPageProps extends FieldFormatOptions {
29
+ registration: DocumentPageRegistration
30
+ record: Record<string, unknown>
31
+ /**
32
+ * Ejecuta una acción. El host decide: abrir el modal de la acción del
33
+ * modelo (`def.modelAction ?? def.key`), navegar (`def.href`), etc. La
34
+ * promesa mantiene el botón en `loading`.
35
+ */
36
+ onAction: (action: ResolvedAction, ctx: DocumentContext) => void | Promise<unknown>
37
+ /** UI del host para pestañas `kind: 'slot'` (historial, archivos, vista previa PDF…). */
38
+ slots?: Record<string, DocumentSlotRenderer>
39
+ /** Navegación del host (botones inteligentes, cliente). */
40
+ onNavigate?: (href: string) => void
41
+ renderLink?: (props: { href: string; children: ReactNode; className?: string }) => ReactNode
42
+ /** Botón «Volver» u otro contenido antes del título. */
43
+ leading?: ReactNode
44
+ /** Pestaña inicial (`?tab=`). */
45
+ defaultTab?: string
46
+ /** Pestaña controlada por el host (p. ej. «Ver detalle del error» abre Datos fiscales). */
47
+ tab?: string
48
+ onTabChange?: (tab: string) => void
49
+ /** Ruta base para el enlace del subtítulo (`/m/customers/{{customer_id}}`). */
50
+ subtitleHrefTemplate?: string
51
+ /** Cambia para forzar recarga de las fuentes tras una acción. */
52
+ reloadKey?: number
53
+ className?: string
54
+ }
55
+
56
+ export function DocumentPage(props: DocumentPageProps) {
57
+ const { registration, record, onAction, slots, onNavigate, renderLink, leading, defaultTab, className } = props
58
+ const { spec } = registration
59
+ const { t } = useTranslation()
60
+ const fmt: FieldFormatOptions = { locale: props.locale, currency: props.currency, timeZone: props.timeZone }
61
+
62
+ const recordId = record.id as string | number | undefined
63
+ const { sources, reload } = useDocumentSources(spec.sources, recordId)
64
+
65
+ const ctx: DocumentContext = useMemo(() => {
66
+ const base = { record, sources }
67
+ return { ...base, derived: registration.derive?.(base) ?? {} }
68
+ }, [record, sources, registration])
69
+
70
+ // Recarga las fuentes cuando el host avisa de un cambio (acción ejecutada).
71
+ const [lastReload, setLastReload] = useState(props.reloadKey ?? 0)
72
+ if ((props.reloadKey ?? 0) !== lastReload) {
73
+ setLastReload(props.reloadKey ?? 0)
74
+ reload()
75
+ }
76
+
77
+ const handleAction = (a: ResolvedAction) => {
78
+ const { tab: targetTab, openUrl, href } = a.def
79
+ if (targetTab) return setTab(targetTab)
80
+ if (openUrl) {
81
+ const url = interpolate(openUrl, ctx)
82
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
83
+ return
84
+ }
85
+ if (href) return onNavigate?.(interpolate(href, ctx))
86
+ return onAction(a, ctx)
87
+ }
88
+
89
+ const layout = useMemo(() => resolveActions(spec, ctx), [spec, ctx])
90
+
91
+ const badges: HeaderBadge[] = spec.statuses
92
+ .map((m): HeaderBadge | null => {
93
+ const status = resolveStatus(m, ctx)
94
+ return status ? { id: m.id, label: m.label, status } : null
95
+ })
96
+ .filter((b): b is HeaderBadge => !!b)
97
+
98
+ const metrics: HeaderMetric[] = (spec.metrics ?? [])
99
+ .filter((m) => !m.hideEmpty || readPath(ctx, m.path) != null)
100
+ .map((m) => ({
101
+ label: t(m.label, { defaultValue: m.label }),
102
+ value: formatFieldValue(readPath(ctx, m.path), m.format, fmt),
103
+ tone: m.tone,
104
+ mono: m.format === 'mono',
105
+ }))
106
+
107
+ const subtitleRaw = spec.subtitle ? readPath(ctx, spec.subtitle.path) : undefined
108
+ const subtitleText = subtitleRaw == null || subtitleRaw === '' ? '—' : formatFieldValue(subtitleRaw, 'text', fmt)
109
+ const subtitle = subtitleText === '—' ? undefined : subtitleText
110
+ const subtitleHref = subtitle && props.subtitleHrefTemplate ? interpolate(props.subtitleHrefTemplate, ctx) : undefined
111
+
112
+ const copyValue = spec.copyable ? readPath(ctx, spec.copyable.path) : undefined
113
+
114
+ const [innerTab, setInnerTab] = useState(defaultTab ?? spec.tabs[0]?.key)
115
+ const tab = props.tab ?? innerTab
116
+ const setTab = (key: string) => {
117
+ setInnerTab(key)
118
+ props.onTabChange?.(key)
119
+ }
120
+
121
+ const smart: SmartButtonItem[] = (spec.smartButtons ?? [])
122
+ .map((b): SmartButtonItem | null => {
123
+ const count = b.source ? sources[b.source]?.length ?? 0 : undefined
124
+ const active = b.source ? (count ?? 0) > 0 : b.field ? !!readPath(ctx, b.field) : true
125
+ if (b.hideWhenEmpty && !active) return null
126
+ const href = b.href ? interpolate(b.href, ctx) : undefined
127
+ return {
128
+ key: b.key,
129
+ label: b.label,
130
+ icon: b.icon,
131
+ count,
132
+ disabled: !active && !b.tab,
133
+ onClick: () => {
134
+ if (b.tab) setTab(b.tab)
135
+ else if (href) onNavigate?.(href)
136
+ },
137
+ }
138
+ })
139
+ .filter((b): b is SmartButtonItem => !!b)
140
+
141
+ return (
142
+ <div className={cn('flex flex-col gap-4', className)} data-document-page={spec.model}>
143
+ <DocumentHeader
144
+ title={resolveTitle(spec.title, ctx) || t('document.untitled', { defaultValue: 'Documento' })}
145
+ subtitle={subtitle}
146
+ subtitleHref={subtitleHref}
147
+ badges={badges}
148
+ metrics={metrics}
149
+ copyable={
150
+ spec.copyable && copyValue
151
+ ? { label: t(spec.copyable.label, { defaultValue: spec.copyable.label }), value: String(copyValue) }
152
+ : undefined
153
+ }
154
+ leading={leading}
155
+ renderLink={renderLink}
156
+ actions={<ActionBar layout={layout} onAction={(a) => handleAction(a)} />}
157
+ />
158
+
159
+ <SmartButtons items={smart} />
160
+
161
+ <Tabs value={tab} onValueChange={setTab} className='flex flex-col gap-4'>
162
+ <TabsList className='w-fit'>
163
+ {spec.tabs.map((tb) => (
164
+ <TabsTrigger key={tb.key} value={tb.key}>
165
+ {t(tb.label, { defaultValue: tb.label })}
166
+ {tabCount(tb, ctx) !== undefined ? (
167
+ <span className='ml-1.5 rounded-full bg-muted px-1.5 text-xs tabular-nums'>{tabCount(tb, ctx)}</span>
168
+ ) : null}
169
+ </TabsTrigger>
170
+ ))}
171
+ </TabsList>
172
+ {spec.tabs.map((tb) => (
173
+ <TabsContent key={tb.key} value={tb.key} className='mt-0'>
174
+ {renderTab(tb, spec, ctx, fmt, slots)}
175
+ </TabsContent>
176
+ ))}
177
+ </Tabs>
178
+ </div>
179
+ )
180
+ }
181
+
182
+ function tabCount(tb: DocumentTabDef, ctx: DocumentContext): number | undefined {
183
+ if (tb.kind === 'records') return ctx.sources[tb.source]?.length
184
+ if (tb.kind === 'slot' && tb.count) {
185
+ const v = readPath(ctx, tb.count)
186
+ return typeof v === 'number' ? v : undefined
187
+ }
188
+ return undefined
189
+ }
190
+
191
+ function renderTab(
192
+ tb: DocumentTabDef,
193
+ spec: DocumentSpec,
194
+ ctx: DocumentContext,
195
+ fmt: FieldFormatOptions,
196
+ slots: Record<string, DocumentSlotRenderer> | undefined,
197
+ ): ReactNode {
198
+ if (tb.kind === 'slot') return slots?.[tb.slot]?.(ctx) ?? null
199
+ if (tb.kind === 'fields') {
200
+ return (
201
+ <div className='flex flex-col gap-4'>
202
+ {tb.groups.map((g, i) => {
203
+ const fields = g.fields.filter((f: DocumentFieldDef) => !f.hideEmpty || readPath(ctx, f.path) != null)
204
+ if (fields.length === 0) return null
205
+ return (
206
+ <section key={g.title ?? i} className='rounded-lg border bg-card p-5'>
207
+ {g.title ? <h2 className='mb-3 text-sm font-semibold'>{g.title}</h2> : null}
208
+ <dl className='grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2 lg:grid-cols-3'>
209
+ {fields.map((f) => (
210
+ <div key={f.path} className={cn(f.wide && 'sm:col-span-2 lg:col-span-3')}>
211
+ <dt className='text-xs uppercase tracking-wide text-muted-foreground'>{f.label}</dt>
212
+ <dd className={cn('break-words font-medium', f.format === 'mono' && 'font-mono text-xs')}>
213
+ {formatFieldValue(readPath(ctx, f.path), f.format, fmt)}
214
+ </dd>
215
+ </div>
216
+ ))}
217
+ </dl>
218
+ </section>
219
+ )
220
+ })}
221
+ </div>
222
+ )
223
+ }
224
+ const rows = ctx.sources[tb.source] ?? []
225
+ if (rows.length === 0) {
226
+ return (
227
+ <p className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>
228
+ {tb.empty ?? 'Sin registros.'}
229
+ </p>
230
+ )
231
+ }
232
+ return (
233
+ <div className='overflow-x-auto rounded-lg border bg-card'>
234
+ <table className='w-full text-sm'>
235
+ <thead className='bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground'>
236
+ <tr>
237
+ {tb.columns.map((c) => (
238
+ <th key={c.path} className='px-3 py-2 font-medium'>{c.label}</th>
239
+ ))}
240
+ </tr>
241
+ </thead>
242
+ <tbody>
243
+ {rows.map((row, i) => (
244
+ <tr key={String(row.id ?? i)} className='border-t'>
245
+ {tb.columns.map((c) => (
246
+ <td key={c.path} className='px-3 py-2'>{renderCell(c, row, spec, fmt)}</td>
247
+ ))}
248
+ </tr>
249
+ ))}
250
+ </tbody>
251
+ </table>
252
+ </div>
253
+ )
254
+ }
255
+
256
+ function renderCell(col: DocumentColumnDef, row: Record<string, unknown>, spec: DocumentSpec, fmt: FieldFormatOptions): ReactNode {
257
+ const v = row[col.path]
258
+ if (col.status) {
259
+ const machine = findStatusMachine(spec, col.status)
260
+ const st = machine?.states.find((s) => s.value === String(v))
261
+ if (st) return <StatusBadge status={st} />
262
+ }
263
+ return (
264
+ <span className={cn(col.format === 'mono' && 'font-mono text-xs', 'tabular-nums')}>
265
+ {formatFieldValue(v, col.format, fmt)}
266
+ </span>
267
+ )
268
+ }
@@ -0,0 +1,66 @@
1
+ // DocumentTimeline — línea de tiempo de eventos de negocio del documento
2
+ // («creó», «timbró», «envió a…», «abrió», «pagó», «solicitó cancelación»,
3
+ // «respuesta del SAT»). Complementa a <RecordHistory> (que muestra diffs de
4
+ // campos): aquí cada entrada es un hecho legible, con actor y hora.
5
+ import { useTranslation } from 'react-i18next'
6
+ import { Circle } from 'lucide-react'
7
+ import { cn } from '@asteby/metacore-ui/lib'
8
+ import { DynamicIcon } from '../dynamic-icon'
9
+ import { formatFieldValue, type FieldFormatOptions } from './format'
10
+ import type { StatusTone } from './types'
11
+
12
+ export interface TimelineEntry {
13
+ id: string
14
+ at: string
15
+ /** Texto ya redactado («Timbró la factura»). */
16
+ label: string
17
+ actor?: string
18
+ detail?: string
19
+ icon?: string
20
+ tone?: StatusTone
21
+ }
22
+
23
+ export interface DocumentTimelineProps extends FieldFormatOptions {
24
+ entries: TimelineEntry[]
25
+ /** Texto del estado vacío; lleva `emptyAction` si el host da una acción. */
26
+ emptyText?: string
27
+ className?: string
28
+ }
29
+
30
+ const TONE_DOT: Record<StatusTone, string> = {
31
+ neutral: 'text-muted-foreground',
32
+ info: 'text-[var(--info,oklch(0.55_0.13_235))]',
33
+ success: 'text-[var(--success,oklch(0.6_0.15_155))]',
34
+ caution: 'text-[var(--warning,oklch(0.6_0.15_65))]',
35
+ warning: 'text-[var(--warning,oklch(0.6_0.15_65))]',
36
+ critical: 'text-destructive',
37
+ }
38
+
39
+ export function DocumentTimeline({ entries, emptyText, className, ...fmt }: DocumentTimelineProps) {
40
+ const { t } = useTranslation()
41
+ if (entries.length === 0) {
42
+ return (
43
+ <p className='rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground'>
44
+ {emptyText ?? t('document.timeline_empty', { defaultValue: 'Aún no hay actividad en este documento.' })}
45
+ </p>
46
+ )
47
+ }
48
+ const sorted = [...entries].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
49
+ return (
50
+ <ol className={cn('relative flex flex-col gap-4 border-l pl-5', className)} data-document-timeline=''>
51
+ {sorted.map((e) => (
52
+ <li key={e.id} className='relative'>
53
+ <span className={cn('absolute -left-[1.85rem] flex h-5 w-5 items-center justify-center rounded-full bg-card', TONE_DOT[e.tone ?? 'neutral'])}>
54
+ {e.icon ? <DynamicIcon name={e.icon} className='h-4 w-4' /> : <Circle className='h-3 w-3 fill-current' />}
55
+ </span>
56
+ <p className='text-sm font-medium'>{t(e.label, { defaultValue: e.label })}</p>
57
+ <p className='text-xs text-muted-foreground'>
58
+ {formatFieldValue(e.at, 'datetime', fmt)}
59
+ {e.actor ? ` · ${e.actor}` : ''}
60
+ </p>
61
+ {e.detail ? <p className='mt-1 whitespace-pre-wrap text-sm text-muted-foreground'>{e.detail}</p> : null}
62
+ </li>
63
+ ))}
64
+ </ol>
65
+ )
66
+ }
@@ -0,0 +1,180 @@
1
+ import type {
2
+ ActionLayout,
3
+ DocumentActionDef,
4
+ DocumentContext,
5
+ DocumentSpec,
6
+ Predicate,
7
+ ResolvedAction,
8
+ ResolvedActionLayout,
9
+ StatusDef,
10
+ StatusMachine,
11
+ When,
12
+ } from './types'
13
+
14
+ /** Máximo de secundarias visibles; el resto pasa a «Más…» (§5.2 / §6.2). */
15
+ export const MAX_VISIBLE_SECONDARY = 3
16
+
17
+ /**
18
+ * Lee una ruta del contexto. `$.x` son los derivados; `sources.k.length` es el
19
+ * total de una fuente; el resto cuelga del registro (`record.x` o `x`).
20
+ */
21
+ export function readPath(ctx: DocumentContext, path: string): unknown {
22
+ if (!path) return undefined
23
+ let parts = path.split('.')
24
+ let cur: unknown
25
+ if (parts[0] === '$') {
26
+ cur = ctx.derived
27
+ parts = parts.slice(1)
28
+ } else if (parts[0] === 'sources') {
29
+ cur = ctx.sources
30
+ parts = parts.slice(1)
31
+ } else {
32
+ cur = ctx.record
33
+ if (parts[0] === 'record') parts = parts.slice(1)
34
+ }
35
+ for (const p of parts) {
36
+ if (cur == null) return undefined
37
+ if (p === 'length' && Array.isArray(cur)) {
38
+ cur = cur.length
39
+ continue
40
+ }
41
+ cur = (cur as Record<string, unknown>)[p]
42
+ }
43
+ return cur
44
+ }
45
+
46
+ function sameValue(a: unknown, b: unknown): boolean {
47
+ if (a == null || b == null) return a == b
48
+ return String(a) === String(b)
49
+ }
50
+
51
+ function isTruthy(v: unknown): boolean {
52
+ if (v == null || v === false || v === '' || v === 0) return false
53
+ if (Array.isArray(v)) return v.length > 0
54
+ return true
55
+ }
56
+
57
+ function toNumber(v: unknown): number {
58
+ const n = typeof v === 'number' ? v : parseFloat(String(v))
59
+ return Number.isFinite(n) ? n : NaN
60
+ }
61
+
62
+ export function evaluatePredicate(p: Predicate, ctx: DocumentContext): boolean {
63
+ const v = readPath(ctx, p.field)
64
+ switch (p.op) {
65
+ case 'eq':
66
+ return sameValue(v, p.value)
67
+ case 'neq':
68
+ return !sameValue(v, p.value)
69
+ case 'in':
70
+ return Array.isArray(p.value) && p.value.some((x) => sameValue(v, x))
71
+ case 'not_in':
72
+ return !(Array.isArray(p.value) && p.value.some((x) => sameValue(v, x)))
73
+ case 'truthy':
74
+ return isTruthy(v)
75
+ case 'falsy':
76
+ return !isTruthy(v)
77
+ case 'gt':
78
+ return toNumber(v) > toNumber(p.value)
79
+ case 'gte':
80
+ return toNumber(v) >= toNumber(p.value)
81
+ case 'lt':
82
+ return toNumber(v) < toNumber(p.value)
83
+ case 'lte':
84
+ return toNumber(v) <= toNumber(p.value)
85
+ default:
86
+ return false
87
+ }
88
+ }
89
+
90
+ /** Un arreglo es un AND; sin condición siempre se cumple. */
91
+ export function evaluateWhen(when: When | undefined, ctx: DocumentContext): boolean {
92
+ if (!when) return true
93
+ const list = Array.isArray(when) ? when : [when]
94
+ return list.every((p) => evaluatePredicate(p, ctx))
95
+ }
96
+
97
+ /** Estado actual de una máquina para el registro (o su fallback / un estado desconocido explícito). */
98
+ export function resolveStatus(machine: StatusMachine, ctx: DocumentContext): StatusDef | undefined {
99
+ const raw = readPath(ctx, machine.field)
100
+ const value = raw == null ? '' : String(raw)
101
+ const found = machine.states.find((s) => s.value === value)
102
+ if (found) return found
103
+ if (machine.fallback) return machine.fallback
104
+ if (!value) return undefined
105
+ return { value, label: value, tone: 'neutral' }
106
+ }
107
+
108
+ /**
109
+ * Distribución de acciones para el estado actual. Una acción `hiddenWhen` se
110
+ * omite; una `blockedWhen` queda deshabilitada con motivo. Más de
111
+ * `MAX_VISIBLE_SECONDARY` secundarias se derraman a «Más…»; una acción marcada
112
+ * `destructive` siempre va a la zona destructiva aunque el layout la ponga en otro lado.
113
+ */
114
+ export function resolveActions(spec: DocumentSpec, ctx: DocumentContext): ResolvedActionLayout {
115
+ const byKey = new Map<string, DocumentActionDef>(spec.actions.map((a) => [a.key, a]))
116
+ const layout: ActionLayout =
117
+ spec.layouts.find((r) => evaluateWhen(r.when, ctx))?.layout ?? spec.defaultLayout ?? {}
118
+
119
+ const resolve = (key: string | undefined): ResolvedAction | undefined => {
120
+ if (!key) return undefined
121
+ const def = byKey.get(key)
122
+ if (!def || (def.hiddenWhen && evaluateWhen(def.hiddenWhen, ctx))) return undefined
123
+ const rules = def.blockedWhen ? (Array.isArray(def.blockedWhen) ? def.blockedWhen : [def.blockedWhen]) : []
124
+ const hit = rules.find((r) => evaluateWhen(r.when, ctx))
125
+ return { def, blockedReason: hit?.reason }
126
+ }
127
+ const many = (keys: string[] | undefined) =>
128
+ (keys ?? []).map(resolve).filter((a): a is ResolvedAction => !!a)
129
+
130
+ const destructive = many(layout.destructive)
131
+ const secondary: ResolvedAction[] = []
132
+ const more: ResolvedAction[] = []
133
+ for (const a of [...many(layout.secondary)]) {
134
+ if (a.def.destructive) destructive.push(a)
135
+ else secondary.push(a)
136
+ }
137
+ for (const a of many(layout.more)) {
138
+ if (a.def.destructive) destructive.push(a)
139
+ else more.push(a)
140
+ }
141
+ const overflow = secondary.splice(MAX_VISIBLE_SECONDARY)
142
+ more.unshift(...overflow)
143
+
144
+ let primary = resolve(layout.primary)
145
+ if (primary?.def.destructive) {
146
+ destructive.unshift(primary)
147
+ primary = undefined
148
+ }
149
+ return { primary, secondary, more, destructive }
150
+ }
151
+
152
+ /** Interpola `{{campo}}` con valores del registro (sin evaluar código). */
153
+ export function interpolate(template: string, ctx: DocumentContext): string {
154
+ return template.replace(/\{\{\s*([\w.$]+)\s*\}\}/g, (_, path: string) => {
155
+ const v = readPath(ctx, path)
156
+ return v == null ? '' : String(v)
157
+ })
158
+ }
159
+
160
+ /** Título: la interpolación puede dejar vacío el resultado; se prueban alternativas separadas por `||`. */
161
+ export function resolveTitle(template: string, ctx: DocumentContext): string {
162
+ for (const alt of template.split('||')) {
163
+ const out = interpolate(alt.trim(), ctx).trim()
164
+ if (out) return out
165
+ }
166
+ return ''
167
+ }
168
+
169
+ /**
170
+ * Quita del spec las acciones que dependen de una acción del modelo que el
171
+ * backend (o el addon que la aporta) no declara: mejor un botón menos que uno
172
+ * que abre un modal vacío. Las que resuelve la propia página (`href`/`openUrl`/`tab`) siempre se conservan.
173
+ */
174
+ export function pruneUnavailableActions(spec: DocumentSpec, availableModelActions: Iterable<string>): DocumentSpec {
175
+ const have = new Set(availableModelActions)
176
+ return {
177
+ ...spec,
178
+ actions: spec.actions.filter((a) => a.href || a.openUrl || a.tab || have.has(a.modelAction ?? a.key)),
179
+ }
180
+ }
@@ -0,0 +1,59 @@
1
+ import type { DocumentFieldDef } from './types'
2
+
3
+ export interface FieldFormatOptions {
4
+ locale?: string
5
+ currency?: string
6
+ timeZone?: string
7
+ }
8
+
9
+ /** Formatea un valor de campo; vacío → «—». Moneda y zona horaria son config de la org, no se hardcodean. */
10
+ export function formatFieldValue(
11
+ value: unknown,
12
+ format: DocumentFieldDef['format'] = 'text',
13
+ opts: FieldFormatOptions = {},
14
+ ): string {
15
+ if (value === null || value === undefined || value === '') return '—'
16
+ const locale = opts.locale || 'es-MX'
17
+ // Relación resuelta por el kernel: `{value,label}` o un objeto con `name`.
18
+ if (typeof value === 'object' && !(value instanceof Date)) {
19
+ const o = value as Record<string, unknown>
20
+ const text = o.label ?? o.name ?? o.title
21
+ return text == null || text === '' ? '—' : String(text)
22
+ }
23
+ switch (format) {
24
+ case 'money': {
25
+ const n = typeof value === 'number' ? value : parseFloat(String(value))
26
+ if (!Number.isFinite(n)) return '—'
27
+ try {
28
+ return new Intl.NumberFormat(locale, {
29
+ style: 'currency',
30
+ currency: opts.currency || 'USD',
31
+ }).format(n)
32
+ } catch {
33
+ return n.toFixed(2)
34
+ }
35
+ }
36
+ case 'percent': {
37
+ const n = typeof value === 'number' ? value : parseFloat(String(value))
38
+ return Number.isFinite(n) ? `${n}%` : '—'
39
+ }
40
+ case 'date':
41
+ case 'datetime': {
42
+ const d = new Date(String(value))
43
+ if (Number.isNaN(d.getTime())) return String(value)
44
+ try {
45
+ return new Intl.DateTimeFormat(locale, {
46
+ dateStyle: 'medium',
47
+ ...(format === 'datetime' ? { timeStyle: 'short' } : {}),
48
+ timeZone: opts.timeZone || undefined,
49
+ }).format(d)
50
+ } catch {
51
+ return d.toISOString()
52
+ }
53
+ }
54
+ case 'boolean':
55
+ return value ? 'Sí' : 'No'
56
+ default:
57
+ return String(value)
58
+ }
59
+ }
@@ -0,0 +1,28 @@
1
+ export * from './types'
2
+ export {
3
+ MAX_VISIBLE_SECONDARY,
4
+ evaluatePredicate,
5
+ evaluateWhen,
6
+ interpolate,
7
+ pruneUnavailableActions,
8
+ readPath,
9
+ resolveActions,
10
+ resolveStatus,
11
+ resolveTitle,
12
+ } from './evaluate'
13
+ export { formatFieldValue, type FieldFormatOptions } from './format'
14
+ export { StatusBadge, STATUS_TONE_STYLE, type StatusBadgeProps } from './status-badge'
15
+ export { ActionBar, type ActionBarProps } from './action-bar'
16
+ export { DocumentHeader, type DocumentHeaderProps, type HeaderBadge, type HeaderMetric } from './document-header'
17
+ export { SmartButtons, type SmartButtonItem, type SmartButtonsProps } from './smart-buttons'
18
+ export { DocumentTimeline, type DocumentTimelineProps, type TimelineEntry } from './document-timeline'
19
+ export { CancelWithReason, type CancelPayload, type CancelReason, type CancelWithReasonProps } from './cancel-with-reason'
20
+ export { DocumentPage, type DocumentPageProps } from './document-page'
21
+ export { useDocumentSources, type DocumentSourcesState } from './use-document-sources'
22
+ export {
23
+ clearDocumentPages,
24
+ findStatusMachine,
25
+ getDocumentPage,
26
+ registerDocumentPage,
27
+ type DocumentPageRegistration,
28
+ } from './registry'
@@ -0,0 +1,39 @@
1
+ // Registro por modelo de la «página de documento». Igual que
2
+ // `registerModelExtension`: singleton de módulo, se registra una vez al arrancar
3
+ // (o desde el `register()` federado de un addon) y la ruta de detalle del host
4
+ // compone la página sin código por modelo.
5
+ import type { DocumentContext, DocumentSpec, StatusMachine } from './types'
6
+
7
+ export interface DocumentPageRegistration {
8
+ spec: DocumentSpec
9
+ /**
10
+ * Calcula derivados (`$.x`) a partir del registro y las fuentes cargadas —
11
+ * p. ej. el estado fiscal a partir de fiscal_documents y el de cobro a
12
+ * partir de saldo y vencimiento. Función pura, sin efectos.
13
+ */
14
+ derive?: (ctx: Omit<DocumentContext, 'derived'>) => Record<string, unknown>
15
+ }
16
+
17
+ const registry = new Map<string, DocumentPageRegistration>()
18
+
19
+ /**
20
+ * Registra la página bajo `spec.model` y, opcionalmente, bajo alias (la ruta
21
+ * `/m/<model>` puede llegar como `invoices` o `Invoice` según el enlace).
22
+ */
23
+ export function registerDocumentPage(reg: DocumentPageRegistration, aliases: string[] = []): void {
24
+ registry.set(reg.spec.model, reg)
25
+ for (const a of aliases) registry.set(a, reg)
26
+ }
27
+
28
+ export function getDocumentPage(model: string): DocumentPageRegistration | undefined {
29
+ return registry.get(model)
30
+ }
31
+
32
+ export function clearDocumentPages(): void {
33
+ registry.clear()
34
+ }
35
+
36
+ /** Catálogo compartido de estados por id de máquina (lo usan las columnas `status` de las pestañas). */
37
+ export function findStatusMachine(spec: DocumentSpec, id: string): StatusMachine | undefined {
38
+ return spec.statuses.find((s) => s.id === id) ?? spec.catalogs?.find((s) => s.id === id)
39
+ }