@asteby/metacore-runtime-react 45.0.0 → 45.3.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 (177) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/action-modal-dispatcher.d.ts +29 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +115 -6
  5. package/dist/activity-diff.d.ts +2 -0
  6. package/dist/activity-diff.d.ts.map +1 -1
  7. package/dist/activity-diff.js +2 -2
  8. package/dist/approval-events.d.ts +16 -0
  9. package/dist/approval-events.d.ts.map +1 -0
  10. package/dist/approval-events.js +27 -0
  11. package/dist/approval-gate.d.ts +39 -0
  12. package/dist/approval-gate.d.ts.map +1 -0
  13. package/dist/approval-gate.js +110 -0
  14. package/dist/approval-pin-dialog.d.ts +25 -0
  15. package/dist/approval-pin-dialog.d.ts.map +1 -0
  16. package/dist/approval-pin-dialog.js +66 -0
  17. package/dist/business/catalog.d.ts +16 -0
  18. package/dist/business/catalog.d.ts.map +1 -0
  19. package/dist/business/catalog.js +50 -0
  20. package/dist/business/customer-picker.d.ts +39 -0
  21. package/dist/business/customer-picker.d.ts.map +1 -0
  22. package/dist/business/customer-picker.js +51 -0
  23. package/dist/business/feedback.d.ts +72 -0
  24. package/dist/business/feedback.d.ts.map +1 -0
  25. package/dist/business/feedback.js +52 -0
  26. package/dist/business/field-errors.d.ts +26 -0
  27. package/dist/business/field-errors.d.ts.map +1 -0
  28. package/dist/business/field-errors.js +59 -0
  29. package/dist/business/format.d.ts +49 -0
  30. package/dist/business/format.d.ts.map +1 -0
  31. package/dist/business/format.js +128 -0
  32. package/dist/business/index.d.ts +16 -0
  33. package/dist/business/index.d.ts.map +1 -0
  34. package/dist/business/index.js +15 -0
  35. package/dist/business/line-items-editor.d.ts +25 -0
  36. package/dist/business/line-items-editor.d.ts.map +1 -0
  37. package/dist/business/line-items-editor.js +36 -0
  38. package/dist/business/line-items.d.ts +69 -0
  39. package/dist/business/line-items.d.ts.map +1 -0
  40. package/dist/business/line-items.js +133 -0
  41. package/dist/business/payment-capture.d.ts +21 -0
  42. package/dist/business/payment-capture.d.ts.map +1 -0
  43. package/dist/business/payment-capture.js +24 -0
  44. package/dist/business/payment.d.ts +61 -0
  45. package/dist/business/payment.d.ts.map +1 -0
  46. package/dist/business/payment.js +74 -0
  47. package/dist/business/product-picker.d.ts +24 -0
  48. package/dist/business/product-picker.d.ts.map +1 -0
  49. package/dist/business/product-picker.js +48 -0
  50. package/dist/business/product-search.d.ts +70 -0
  51. package/dist/business/product-search.d.ts.map +1 -0
  52. package/dist/business/product-search.js +51 -0
  53. package/dist/business/refund-destination.d.ts +19 -0
  54. package/dist/business/refund-destination.d.ts.map +1 -0
  55. package/dist/business/refund-destination.js +31 -0
  56. package/dist/business/refund.d.ts +29 -0
  57. package/dist/business/refund.d.ts.map +1 -0
  58. package/dist/business/refund.js +44 -0
  59. package/dist/business/return-wizard.d.ts +23 -0
  60. package/dist/business/return-wizard.d.ts.map +1 -0
  61. package/dist/business/return-wizard.js +59 -0
  62. package/dist/business/return.d.ts +90 -0
  63. package/dist/business/return.d.ts.map +1 -0
  64. package/dist/business/return.js +112 -0
  65. package/dist/business/use-async-search.d.ts +10 -0
  66. package/dist/business/use-async-search.d.ts.map +1 -0
  67. package/dist/business/use-async-search.js +38 -0
  68. package/dist/document/action-bar.d.ts +12 -0
  69. package/dist/document/action-bar.d.ts.map +1 -0
  70. package/dist/document/action-bar.js +39 -0
  71. package/dist/document/cancel-with-reason.d.ts +47 -0
  72. package/dist/document/cancel-with-reason.d.ts.map +1 -0
  73. package/dist/document/cancel-with-reason.js +52 -0
  74. package/dist/document/document-header.d.ts +41 -0
  75. package/dist/document/document-header.d.ts.map +1 -0
  76. package/dist/document/document-header.js +30 -0
  77. package/dist/document/document-page.d.ts +37 -0
  78. package/dist/document/document-page.d.ts.map +1 -0
  79. package/dist/document/document-page.js +137 -0
  80. package/dist/document/document-timeline.d.ts +20 -0
  81. package/dist/document/document-timeline.d.ts.map +1 -0
  82. package/dist/document/document-timeline.js +26 -0
  83. package/dist/document/evaluate.d.ts +31 -0
  84. package/dist/document/evaluate.d.ts.map +1 -0
  85. package/dist/document/evaluate.js +170 -0
  86. package/dist/document/format.d.ts +9 -0
  87. package/dist/document/format.d.ts.map +1 -0
  88. package/dist/document/format.js +52 -0
  89. package/dist/document/index.d.ts +13 -0
  90. package/dist/document/index.d.ts.map +1 -0
  91. package/dist/document/index.js +12 -0
  92. package/dist/document/registry.d.ts +20 -0
  93. package/dist/document/registry.d.ts.map +1 -0
  94. package/dist/document/registry.js +20 -0
  95. package/dist/document/smart-buttons.d.ts +16 -0
  96. package/dist/document/smart-buttons.d.ts.map +1 -0
  97. package/dist/document/smart-buttons.js +16 -0
  98. package/dist/document/status-badge.d.ts +16 -0
  99. package/dist/document/status-badge.d.ts.map +1 -0
  100. package/dist/document/status-badge.js +23 -0
  101. package/dist/document/types.d.ts +196 -0
  102. package/dist/document/types.d.ts.map +1 -0
  103. package/dist/document/types.js +1 -0
  104. package/dist/document/use-document-sources.d.ts +14 -0
  105. package/dist/document/use-document-sources.d.ts.map +1 -0
  106. package/dist/document/use-document-sources.js +72 -0
  107. package/dist/dynamic-columns.d.ts.map +1 -1
  108. package/dist/dynamic-columns.js +3 -1
  109. package/dist/dynamic-relation.d.ts +4 -0
  110. package/dist/dynamic-relation.d.ts.map +1 -1
  111. package/dist/dynamic-relation.js +10 -1
  112. package/dist/dynamic-row-actions.d.ts.map +1 -1
  113. package/dist/dynamic-row-actions.js +14 -2
  114. package/dist/dynamic-table.d.ts.map +1 -1
  115. package/dist/dynamic-table.js +28 -3
  116. package/dist/index.d.ts +5 -0
  117. package/dist/index.d.ts.map +1 -1
  118. package/dist/index.js +6 -0
  119. package/dist/reason-prompt.d.ts +59 -0
  120. package/dist/reason-prompt.d.ts.map +1 -0
  121. package/dist/reason-prompt.js +111 -0
  122. package/dist/server-error.d.ts.map +1 -1
  123. package/dist/server-error.js +19 -0
  124. package/dist/use-addon-settings.d.ts +17 -3
  125. package/dist/use-addon-settings.d.ts.map +1 -1
  126. package/dist/use-addon-settings.js +20 -8
  127. package/package.json +1 -1
  128. package/src/__tests__/approval-gate.test.tsx +134 -0
  129. package/src/__tests__/prefill-from-record.test.ts +69 -0
  130. package/src/__tests__/reason-prompt.test.tsx +121 -0
  131. package/src/__tests__/use-addon-settings.test.ts +16 -1
  132. package/src/action-modal-dispatcher.tsx +145 -3
  133. package/src/activity-diff.tsx +10 -1
  134. package/src/approval-events.ts +39 -0
  135. package/src/approval-gate.tsx +180 -0
  136. package/src/approval-pin-dialog.tsx +162 -0
  137. package/src/business/business.test.ts +210 -0
  138. package/src/business/catalog.ts +68 -0
  139. package/src/business/customer-picker.tsx +201 -0
  140. package/src/business/feedback.tsx +141 -0
  141. package/src/business/field-errors.ts +92 -0
  142. package/src/business/format.ts +151 -0
  143. package/src/business/index.ts +15 -0
  144. package/src/business/line-items-editor.tsx +259 -0
  145. package/src/business/line-items.ts +190 -0
  146. package/src/business/payment-capture.tsx +158 -0
  147. package/src/business/payment.ts +125 -0
  148. package/src/business/product-picker.tsx +171 -0
  149. package/src/business/product-search.ts +108 -0
  150. package/src/business/refund-destination.tsx +121 -0
  151. package/src/business/refund.ts +72 -0
  152. package/src/business/return-wizard.tsx +223 -0
  153. package/src/business/return.ts +181 -0
  154. package/src/business/use-async-search.ts +43 -0
  155. package/src/document/__tests__/document-page.test.tsx +107 -0
  156. package/src/document/__tests__/evaluate.test.ts +140 -0
  157. package/src/document/action-bar.tsx +140 -0
  158. package/src/document/cancel-with-reason.tsx +214 -0
  159. package/src/document/document-header.tsx +140 -0
  160. package/src/document/document-page.tsx +268 -0
  161. package/src/document/document-timeline.tsx +66 -0
  162. package/src/document/evaluate.ts +180 -0
  163. package/src/document/format.ts +59 -0
  164. package/src/document/index.ts +28 -0
  165. package/src/document/registry.ts +39 -0
  166. package/src/document/smart-buttons.tsx +54 -0
  167. package/src/document/status-badge.tsx +44 -0
  168. package/src/document/types.ts +203 -0
  169. package/src/document/use-document-sources.ts +91 -0
  170. package/src/dynamic-columns.tsx +5 -1
  171. package/src/dynamic-relation.tsx +14 -1
  172. package/src/dynamic-row-actions.tsx +13 -1
  173. package/src/dynamic-table.tsx +22 -2
  174. package/src/index.ts +33 -0
  175. package/src/reason-prompt.tsx +247 -0
  176. package/src/server-error.ts +20 -0
  177. package/src/use-addon-settings.ts +26 -7
@@ -0,0 +1,214 @@
1
+ // CancelWithReason — cancelación guiada de 2 pasos (benchmark §5.5 / §7): motivo
2
+ // del catálogo del tipo de documento → (documento sustituto si el motivo lo
3
+ // exige) → confirmación con consecuencias. Genérico: el catálogo de motivos, las
4
+ // consecuencias y la política de aceptación las declara el llamador; el
5
+ // componente no sabe de CFDI. Solo una acción primaria por paso (verbo +
6
+ // sustantivo) y, si la acción falla, el modal sigue abierto con el error.
7
+ import { useEffect, useState, type ReactNode } from 'react'
8
+ import { useTranslation } from 'react-i18next'
9
+ import { AlertTriangle, Loader2 } from 'lucide-react'
10
+ import {
11
+ Button,
12
+ Dialog,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogFooter,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ Input,
19
+ Label,
20
+ RadioGroup,
21
+ RadioGroupItem,
22
+ } from '@asteby/metacore-ui/primitives'
23
+
24
+ export interface CancelReason {
25
+ value: string
26
+ label: string
27
+ /** Ayuda corta bajo el motivo. */
28
+ hint?: string
29
+ /** Exige apuntar a un documento sustituto (motivo 01 del SAT). */
30
+ requiresSubstitute?: boolean
31
+ /** El motivo no aplica a este documento (se ve deshabilitado con `disabledReason`). */
32
+ disabled?: boolean
33
+ disabledReason?: string
34
+ }
35
+
36
+ export interface CancelPayload {
37
+ reason: string
38
+ substitute?: string
39
+ }
40
+
41
+ export interface CancelWithReasonProps {
42
+ open: boolean
43
+ onOpenChange: (open: boolean) => void
44
+ /** Identificación del documento («Factura A-1042»). */
45
+ documentLabel: string
46
+ title?: string
47
+ reasons: CancelReason[]
48
+ /** Consecuencias que se muestran en el paso de confirmación (§6.2). */
49
+ consequences: string[]
50
+ /**
51
+ * Aviso según el motivo elegido, p. ej. «requiere aceptación del receptor».
52
+ * Devuelve `undefined` si no hay.
53
+ */
54
+ notice?: (reason: CancelReason) => string | undefined
55
+ /** Formato del identificador del sustituto; si se define, valida en línea. */
56
+ substitutePattern?: RegExp
57
+ substituteLabel?: string
58
+ substituteHelp?: string
59
+ /** Zona para crear el sustituto desde el mismo asistente (el host la implementa). */
60
+ substituteSlot?: (props: { value: string; onChange: (v: string) => void }) => ReactNode
61
+ /** Ejecuta la cancelación; debe lanzar con `message` si falla. */
62
+ onConfirm: (payload: CancelPayload) => Promise<unknown>
63
+ /** Se llama tras un éxito, antes de cerrar. */
64
+ onDone?: () => void
65
+ confirmLabel?: string
66
+ }
67
+
68
+ type Step = 'reason' | 'confirm'
69
+
70
+ export function CancelWithReason(props: CancelWithReasonProps) {
71
+ const { open, onOpenChange, reasons, consequences, onConfirm, onDone } = props
72
+ const { t } = useTranslation()
73
+ const [step, setStep] = useState<Step>('reason')
74
+ const [reasonValue, setReasonValue] = useState('')
75
+ const [substitute, setSubstitute] = useState('')
76
+ const [busy, setBusy] = useState(false)
77
+ const [error, setError] = useState<string | null>(null)
78
+
79
+ useEffect(() => {
80
+ if (open) {
81
+ setStep('reason')
82
+ setReasonValue('')
83
+ setSubstitute('')
84
+ setError(null)
85
+ setBusy(false)
86
+ }
87
+ }, [open])
88
+
89
+ const reason = reasons.find((r) => r.value === reasonValue)
90
+ const substituteValid =
91
+ !reason?.requiresSubstitute ||
92
+ (substitute.trim().length > 0 && (!props.substitutePattern || props.substitutePattern.test(substitute.trim())))
93
+ const notice = reason ? props.notice?.(reason) : undefined
94
+ const tr = (s: string) => t(s, { defaultValue: s })
95
+
96
+ const confirm = async () => {
97
+ if (!reason) return
98
+ setBusy(true)
99
+ setError(null)
100
+ try {
101
+ await onConfirm({ reason: reason.value, substitute: reason.requiresSubstitute ? substitute.trim() : undefined })
102
+ onDone?.()
103
+ onOpenChange(false)
104
+ } catch (e) {
105
+ setError((e as { message?: string })?.message || t('document.cancel.failed', { defaultValue: 'No se pudo cancelar. Revisa el detalle e intenta de nuevo.' }))
106
+ setBusy(false)
107
+ }
108
+ }
109
+
110
+ return (
111
+ <Dialog open={open} onOpenChange={(o) => (!busy ? onOpenChange(o) : undefined)}>
112
+ <DialogContent className='sm:max-w-lg'>
113
+ <DialogHeader>
114
+ <DialogTitle>{props.title ?? t('document.cancel.title', { defaultValue: 'Solicitar cancelación' })}</DialogTitle>
115
+ <DialogDescription>
116
+ {props.documentLabel} ·{' '}
117
+ {step === 'reason'
118
+ ? t('document.cancel.step_reason', { defaultValue: 'Paso 1 de 2: elige el motivo' })
119
+ : t('document.cancel.step_confirm', { defaultValue: 'Paso 2 de 2: confirma' })}
120
+ </DialogDescription>
121
+ </DialogHeader>
122
+
123
+ {step === 'reason' ? (
124
+ <div className='flex flex-col gap-4'>
125
+ <RadioGroup value={reasonValue} onValueChange={setReasonValue} className='gap-2'>
126
+ {reasons.map((r) => (
127
+ <label
128
+ key={r.value}
129
+ className='flex cursor-pointer items-start gap-3 rounded-md border p-3 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 has-[[data-state=checked]]:border-primary'
130
+ >
131
+ <RadioGroupItem value={r.value} disabled={r.disabled} className='mt-0.5' />
132
+ <span className='flex flex-col'>
133
+ <span className='text-sm font-medium'>{tr(r.label)}</span>
134
+ {r.disabled && r.disabledReason ? (
135
+ <span className='text-xs text-muted-foreground'>{tr(r.disabledReason)}</span>
136
+ ) : r.hint ? (
137
+ <span className='text-xs text-muted-foreground'>{tr(r.hint)}</span>
138
+ ) : null}
139
+ </span>
140
+ </label>
141
+ ))}
142
+ </RadioGroup>
143
+
144
+ {reason?.requiresSubstitute ? (
145
+ props.substituteSlot ? (
146
+ props.substituteSlot({ value: substitute, onChange: setSubstitute })
147
+ ) : (
148
+ <div className='grid gap-2'>
149
+ <Label>{props.substituteLabel ?? t('document.cancel.substitute', { defaultValue: 'Documento sustituto' })}</Label>
150
+ <Input value={substitute} onChange={(e) => setSubstitute(e.target.value)} className='font-mono text-sm' />
151
+ {props.substituteHelp ? <p className='text-xs text-muted-foreground'>{tr(props.substituteHelp)}</p> : null}
152
+ {substitute && !substituteValid ? (
153
+ <p className='text-xs text-destructive'>{t('document.cancel.substitute_invalid', { defaultValue: 'El formato del identificador no es válido.' })}</p>
154
+ ) : null}
155
+ </div>
156
+ )
157
+ ) : null}
158
+ {notice ? <p className='rounded-md bg-muted p-3 text-sm'>{tr(notice)}</p> : null}
159
+ </div>
160
+ ) : (
161
+ <div className='flex flex-col gap-3'>
162
+ <div className='flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive'>
163
+ <AlertTriangle className='mt-0.5 h-4 w-4 shrink-0' />
164
+ <ul className='list-disc pl-4'>
165
+ {consequences.map((c) => (
166
+ <li key={c}>{tr(c)}</li>
167
+ ))}
168
+ </ul>
169
+ </div>
170
+ <dl className='grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm'>
171
+ <dt className='text-muted-foreground'>{t('document.cancel.reason', { defaultValue: 'Motivo' })}</dt>
172
+ <dd className='font-medium'>{reason ? tr(reason.label) : ''}</dd>
173
+ {reason?.requiresSubstitute ? (
174
+ <>
175
+ <dt className='text-muted-foreground'>{t('document.cancel.substitute_short', { defaultValue: 'Sustituto' })}</dt>
176
+ <dd className='break-all font-mono text-xs'>{substitute}</dd>
177
+ </>
178
+ ) : null}
179
+ </dl>
180
+ {notice ? <p className='text-sm text-muted-foreground'>{tr(notice)}</p> : null}
181
+ {error ? (
182
+ <p role='alert' className='rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive'>
183
+ {error}
184
+ </p>
185
+ ) : null}
186
+ </div>
187
+ )}
188
+
189
+ <DialogFooter>
190
+ {step === 'reason' ? (
191
+ <>
192
+ <Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
193
+ {t('document.cancel.keep', { defaultValue: 'Conservar documento' })}
194
+ </Button>
195
+ <Button type='button' disabled={!reason || !substituteValid} onClick={() => setStep('confirm')}>
196
+ {t('document.cancel.continue', { defaultValue: 'Continuar' })}
197
+ </Button>
198
+ </>
199
+ ) : (
200
+ <>
201
+ <Button type='button' variant='outline' disabled={busy} onClick={() => setStep('reason')}>
202
+ {t('document.cancel.back', { defaultValue: 'Volver al motivo' })}
203
+ </Button>
204
+ <Button type='button' variant='destructive' disabled={busy} onClick={confirm}>
205
+ {busy ? <Loader2 className='mr-1.5 h-4 w-4 animate-spin' /> : null}
206
+ {props.confirmLabel ?? t('document.cancel.confirm', { defaultValue: 'Solicitar cancelación' })}
207
+ </Button>
208
+ </>
209
+ )}
210
+ </DialogFooter>
211
+ </DialogContent>
212
+ </Dialog>
213
+ )
214
+ }
@@ -0,0 +1,140 @@
1
+ // DocumentHeader — cabecera estándar (benchmark §5.2): serie/folio, badges de
2
+ // cada máquina de estado, cliente enlazable, métricas (total, saldo, fechas) y
3
+ // un identificador copiable (UUID).
4
+ import { useState, type ReactNode } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+ import { Check, Copy } from 'lucide-react'
7
+ import { Button } from '@asteby/metacore-ui/primitives'
8
+ import { cn } from '@asteby/metacore-ui/lib'
9
+ import { StatusBadge } from './status-badge'
10
+ import { formatFieldValue, type FieldFormatOptions } from './format'
11
+ import type { StatusDef } from './types'
12
+
13
+ export interface HeaderBadge {
14
+ id: string
15
+ label?: string
16
+ status: StatusDef
17
+ }
18
+
19
+ export interface HeaderMetric {
20
+ label: string
21
+ value: string
22
+ tone?: 'default' | 'critical'
23
+ mono?: boolean
24
+ }
25
+
26
+ export interface DocumentHeaderProps {
27
+ title: string
28
+ /** Texto bajo el título; si `subtitleHref` viene, es un enlace (ficha del cliente). */
29
+ subtitle?: string
30
+ subtitleHref?: string
31
+ badges: HeaderBadge[]
32
+ metrics?: HeaderMetric[]
33
+ copyable?: { label: string; value: string }
34
+ /** Zona de acciones (normalmente `<ActionBar/>`). */
35
+ actions?: ReactNode
36
+ /** Slot antes del título (botón «Volver»). */
37
+ leading?: ReactNode
38
+ /** Renderiza el subtítulo como enlace del router del host. */
39
+ renderLink?: (props: { href: string; children: ReactNode; className?: string }) => ReactNode
40
+ className?: string
41
+ }
42
+
43
+ export function DocumentHeader({
44
+ title,
45
+ subtitle,
46
+ subtitleHref,
47
+ badges,
48
+ metrics,
49
+ copyable,
50
+ actions,
51
+ leading,
52
+ renderLink,
53
+ className,
54
+ }: DocumentHeaderProps) {
55
+ const { t } = useTranslation()
56
+ const [copied, setCopied] = useState(false)
57
+
58
+ const copy = async () => {
59
+ if (!copyable?.value) return
60
+ try {
61
+ await navigator.clipboard.writeText(copyable.value)
62
+ setCopied(true)
63
+ setTimeout(() => setCopied(false), 1500)
64
+ } catch {
65
+ /* sin portapapeles (contexto inseguro): el valor sigue seleccionable */
66
+ }
67
+ }
68
+
69
+ const subtitleNode = subtitle ? (
70
+ subtitleHref ? (
71
+ renderLink ? (
72
+ renderLink({ href: subtitleHref, children: subtitle, className: 'text-sm text-primary hover:underline' })
73
+ ) : (
74
+ <a href={subtitleHref} className='text-sm text-primary hover:underline'>
75
+ {subtitle}
76
+ </a>
77
+ )
78
+ ) : (
79
+ <span className='text-sm text-muted-foreground'>{subtitle}</span>
80
+ )
81
+ ) : null
82
+
83
+ return (
84
+ <header className={cn('flex flex-col gap-4 rounded-lg border bg-card p-5 text-card-foreground', className)} data-document-header=''>
85
+ {leading}
86
+ <div className='flex flex-wrap items-start justify-between gap-4'>
87
+ <div className='flex min-w-0 flex-col gap-2'>
88
+ <div className='flex flex-wrap items-center gap-2'>
89
+ <h1 className='text-2xl font-bold tracking-tight'>{title}</h1>
90
+ {badges.map((b) => (
91
+ <StatusBadge key={b.id} status={b.status} srLabel={b.label ? t(b.label, { defaultValue: b.label }) : undefined} />
92
+ ))}
93
+ </div>
94
+ {subtitleNode}
95
+ </div>
96
+ {actions}
97
+ </div>
98
+
99
+ {metrics && metrics.length > 0 ? (
100
+ <dl className='grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3 lg:grid-cols-5'>
101
+ {metrics.map((m) => (
102
+ <div key={m.label}>
103
+ <dt className='text-xs uppercase tracking-wide text-muted-foreground'>{m.label}</dt>
104
+ <dd
105
+ className={cn(
106
+ 'font-medium tabular-nums',
107
+ m.mono && 'font-mono text-xs',
108
+ m.tone === 'critical' && 'text-destructive',
109
+ )}
110
+ >
111
+ {m.value}
112
+ </dd>
113
+ </div>
114
+ ))}
115
+ </dl>
116
+ ) : null}
117
+
118
+ {copyable?.value ? (
119
+ <div className='flex flex-wrap items-center gap-2 text-xs text-muted-foreground'>
120
+ <span className='uppercase tracking-wide'>{copyable.label}</span>
121
+ <code className='break-all font-mono text-foreground'>{copyable.value}</code>
122
+ <Button
123
+ type='button'
124
+ size='sm'
125
+ variant='ghost'
126
+ className='h-6 px-1.5'
127
+ onClick={copy}
128
+ aria-label={t('document.copy', { defaultValue: 'Copiar' })}
129
+ >
130
+ {copied ? <Check className='h-3.5 w-3.5' /> : <Copy className='h-3.5 w-3.5' />}
131
+ <span className='ml-1'>{copied ? t('document.copied', { defaultValue: 'Copiado' }) : t('document.copy', { defaultValue: 'Copiar' })}</span>
132
+ </Button>
133
+ </div>
134
+ ) : null}
135
+ </header>
136
+ )
137
+ }
138
+
139
+ export { formatFieldValue }
140
+ export type { FieldFormatOptions }
@@ -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
+ }