@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,140 @@
1
+ // ActionBar — barra de acciones de un documento (benchmark §5.4 / §6.2):
2
+ // una primaria (verbo + sustantivo), 2–3 secundarias visibles, el resto en
3
+ // «Más…» y las destructivas separadas (divisor) y en rojo. Una acción bloqueada se ve
4
+ // deshabilitada con su motivo (no desaparece: el operador entiende por qué).
5
+ import { useState } from 'react'
6
+ import { useTranslation } from 'react-i18next'
7
+ import { Loader2, MoreHorizontal } from 'lucide-react'
8
+ import {
9
+ Button,
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuItem,
13
+ DropdownMenuTrigger,
14
+ Tooltip,
15
+ TooltipContent,
16
+ TooltipProvider,
17
+ TooltipTrigger,
18
+ } from '@asteby/metacore-ui/primitives'
19
+ import { cn } from '@asteby/metacore-ui/lib'
20
+ import { DynamicIcon } from '../dynamic-icon'
21
+ import type { ResolvedAction, ResolvedActionLayout } from './types'
22
+
23
+ export interface ActionBarProps {
24
+ layout: ResolvedActionLayout
25
+ /**
26
+ * Ejecuta la acción. Si devuelve una promesa, el botón muestra `loading`
27
+ * hasta que se resuelve (acciones asíncronas, §6.2).
28
+ */
29
+ onAction: (action: ResolvedAction) => void | Promise<unknown>
30
+ className?: string
31
+ }
32
+
33
+ export function ActionBar({ layout, onAction, className }: ActionBarProps) {
34
+ const { t } = useTranslation()
35
+ const [busy, setBusy] = useState<string | null>(null)
36
+
37
+ const run = async (a: ResolvedAction) => {
38
+ if (a.blockedReason || busy) return
39
+ setBusy(a.def.key)
40
+ try {
41
+ await onAction(a)
42
+ } finally {
43
+ setBusy(null)
44
+ }
45
+ }
46
+
47
+ const label = (a: ResolvedAction) => t(a.def.label, { defaultValue: a.def.label })
48
+ const { primary, secondary, more, destructive } = layout
49
+ const hasMenu = more.length > 0
50
+
51
+ const button = (a: ResolvedAction, variant: 'default' | 'outline' | 'destructive') => {
52
+ const loading = busy === a.def.key
53
+ const btn = (
54
+ <Button
55
+ key={a.def.key}
56
+ type='button'
57
+ size='sm'
58
+ variant={variant === 'destructive' ? 'outline' : variant}
59
+ disabled={!!a.blockedReason || (!!busy && !loading)}
60
+ aria-disabled={!!a.blockedReason}
61
+ data-action={a.def.key}
62
+ onClick={() => run(a)}
63
+ className={cn(
64
+ variant === 'destructive' &&
65
+ 'border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive',
66
+ )}
67
+ >
68
+ {loading ? (
69
+ <Loader2 className='mr-1.5 h-4 w-4 animate-spin' />
70
+ ) : a.def.icon ? (
71
+ <span className='mr-1.5 inline-flex h-4 w-4 items-center'>
72
+ <DynamicIcon name={a.def.icon} className='h-4 w-4' />
73
+ </span>
74
+ ) : null}
75
+ {label(a)}
76
+ </Button>
77
+ )
78
+ if (!a.blockedReason) return btn
79
+ return (
80
+ <Tooltip key={a.def.key}>
81
+ <TooltipTrigger asChild>
82
+ {/* span: un botón disabled no dispara el tooltip */}
83
+ <span tabIndex={0}>{btn}</span>
84
+ </TooltipTrigger>
85
+ <TooltipContent className='max-w-xs'>{t(a.blockedReason, { defaultValue: a.blockedReason })}</TooltipContent>
86
+ </Tooltip>
87
+ )
88
+ }
89
+
90
+ const item = (a: ResolvedAction) => (
91
+ <DropdownMenuItem
92
+ key={a.def.key}
93
+ disabled={!!a.blockedReason}
94
+ data-action={a.def.key}
95
+ onSelect={() => run(a)}
96
+ >
97
+ {a.def.icon ? (
98
+ <span className='mr-2 inline-flex h-4 w-4 items-center'>
99
+ <DynamicIcon name={a.def.icon} className='h-4 w-4' />
100
+ </span>
101
+ ) : null}
102
+ <span className='flex flex-col'>
103
+ {label(a)}
104
+ {a.blockedReason ? (
105
+ <span className='text-xs font-normal text-muted-foreground'>
106
+ {t(a.blockedReason, { defaultValue: a.blockedReason })}
107
+ </span>
108
+ ) : null}
109
+ </span>
110
+ </DropdownMenuItem>
111
+ )
112
+
113
+ return (
114
+ <TooltipProvider delayDuration={150}>
115
+ <div className={cn('flex flex-wrap items-center gap-2', className)} data-document-actions=''>
116
+ {primary ? button(primary, primary.def.destructive ? 'destructive' : 'default') : null}
117
+ {secondary.map((a) => button(a, 'outline'))}
118
+ {hasMenu ? (
119
+ <DropdownMenu>
120
+ <DropdownMenuTrigger asChild>
121
+ <Button type='button' size='sm' variant='outline' aria-label={t('document.more', { defaultValue: 'Más…' })}>
122
+ <MoreHorizontal className='mr-1.5 h-4 w-4' />
123
+ {t('document.more', { defaultValue: 'Más…' })}
124
+ </Button>
125
+ </DropdownMenuTrigger>
126
+ <DropdownMenuContent align='end' className='min-w-56'>
127
+ {more.map((a) => item(a))}
128
+ </DropdownMenuContent>
129
+ </DropdownMenu>
130
+ ) : null}
131
+ {destructive.length > 0 ? (
132
+ <>
133
+ <span aria-hidden className='mx-1 h-6 w-px bg-border' />
134
+ {destructive.map((a) => button(a, 'destructive'))}
135
+ </>
136
+ ) : null}
137
+ </div>
138
+ </TooltipProvider>
139
+ )
140
+ }
@@ -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 }