@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.
- package/CHANGELOG.md +29 -0
- package/dist/action-modal-dispatcher.d.ts +29 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +115 -6
- package/dist/activity-diff.d.ts +2 -0
- package/dist/activity-diff.d.ts.map +1 -1
- package/dist/activity-diff.js +2 -2
- package/dist/approval-events.d.ts +16 -0
- package/dist/approval-events.d.ts.map +1 -0
- package/dist/approval-events.js +27 -0
- package/dist/approval-gate.d.ts +39 -0
- package/dist/approval-gate.d.ts.map +1 -0
- package/dist/approval-gate.js +110 -0
- package/dist/approval-pin-dialog.d.ts +25 -0
- package/dist/approval-pin-dialog.d.ts.map +1 -0
- package/dist/approval-pin-dialog.js +66 -0
- package/dist/business/catalog.d.ts +16 -0
- package/dist/business/catalog.d.ts.map +1 -0
- package/dist/business/catalog.js +50 -0
- package/dist/business/customer-picker.d.ts +39 -0
- package/dist/business/customer-picker.d.ts.map +1 -0
- package/dist/business/customer-picker.js +51 -0
- package/dist/business/feedback.d.ts +72 -0
- package/dist/business/feedback.d.ts.map +1 -0
- package/dist/business/feedback.js +52 -0
- package/dist/business/field-errors.d.ts +26 -0
- package/dist/business/field-errors.d.ts.map +1 -0
- package/dist/business/field-errors.js +59 -0
- package/dist/business/format.d.ts +49 -0
- package/dist/business/format.d.ts.map +1 -0
- package/dist/business/format.js +128 -0
- package/dist/business/index.d.ts +16 -0
- package/dist/business/index.d.ts.map +1 -0
- package/dist/business/index.js +15 -0
- package/dist/business/line-items-editor.d.ts +25 -0
- package/dist/business/line-items-editor.d.ts.map +1 -0
- package/dist/business/line-items-editor.js +36 -0
- package/dist/business/line-items.d.ts +69 -0
- package/dist/business/line-items.d.ts.map +1 -0
- package/dist/business/line-items.js +133 -0
- package/dist/business/payment-capture.d.ts +21 -0
- package/dist/business/payment-capture.d.ts.map +1 -0
- package/dist/business/payment-capture.js +24 -0
- package/dist/business/payment.d.ts +61 -0
- package/dist/business/payment.d.ts.map +1 -0
- package/dist/business/payment.js +74 -0
- package/dist/business/product-picker.d.ts +24 -0
- package/dist/business/product-picker.d.ts.map +1 -0
- package/dist/business/product-picker.js +48 -0
- package/dist/business/product-search.d.ts +70 -0
- package/dist/business/product-search.d.ts.map +1 -0
- package/dist/business/product-search.js +51 -0
- package/dist/business/refund-destination.d.ts +19 -0
- package/dist/business/refund-destination.d.ts.map +1 -0
- package/dist/business/refund-destination.js +31 -0
- package/dist/business/refund.d.ts +29 -0
- package/dist/business/refund.d.ts.map +1 -0
- package/dist/business/refund.js +44 -0
- package/dist/business/return-wizard.d.ts +23 -0
- package/dist/business/return-wizard.d.ts.map +1 -0
- package/dist/business/return-wizard.js +59 -0
- package/dist/business/return.d.ts +90 -0
- package/dist/business/return.d.ts.map +1 -0
- package/dist/business/return.js +112 -0
- package/dist/business/use-async-search.d.ts +10 -0
- package/dist/business/use-async-search.d.ts.map +1 -0
- package/dist/business/use-async-search.js +38 -0
- package/dist/document/action-bar.d.ts +12 -0
- package/dist/document/action-bar.d.ts.map +1 -0
- package/dist/document/action-bar.js +39 -0
- package/dist/document/cancel-with-reason.d.ts +47 -0
- package/dist/document/cancel-with-reason.d.ts.map +1 -0
- package/dist/document/cancel-with-reason.js +52 -0
- package/dist/document/document-header.d.ts +41 -0
- package/dist/document/document-header.d.ts.map +1 -0
- package/dist/document/document-header.js +30 -0
- package/dist/document/document-page.d.ts +37 -0
- package/dist/document/document-page.d.ts.map +1 -0
- package/dist/document/document-page.js +137 -0
- package/dist/document/document-timeline.d.ts +20 -0
- package/dist/document/document-timeline.d.ts.map +1 -0
- package/dist/document/document-timeline.js +26 -0
- package/dist/document/evaluate.d.ts +31 -0
- package/dist/document/evaluate.d.ts.map +1 -0
- package/dist/document/evaluate.js +170 -0
- package/dist/document/format.d.ts +9 -0
- package/dist/document/format.d.ts.map +1 -0
- package/dist/document/format.js +52 -0
- package/dist/document/index.d.ts +13 -0
- package/dist/document/index.d.ts.map +1 -0
- package/dist/document/index.js +12 -0
- package/dist/document/registry.d.ts +20 -0
- package/dist/document/registry.d.ts.map +1 -0
- package/dist/document/registry.js +20 -0
- package/dist/document/smart-buttons.d.ts +16 -0
- package/dist/document/smart-buttons.d.ts.map +1 -0
- package/dist/document/smart-buttons.js +16 -0
- package/dist/document/status-badge.d.ts +16 -0
- package/dist/document/status-badge.d.ts.map +1 -0
- package/dist/document/status-badge.js +23 -0
- package/dist/document/types.d.ts +196 -0
- package/dist/document/types.d.ts.map +1 -0
- package/dist/document/types.js +1 -0
- package/dist/document/use-document-sources.d.ts +14 -0
- package/dist/document/use-document-sources.d.ts.map +1 -0
- package/dist/document/use-document-sources.js +72 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +3 -1
- package/dist/dynamic-relation.d.ts +4 -0
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +10 -1
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +14 -2
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +28 -3
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/reason-prompt.d.ts +59 -0
- package/dist/reason-prompt.d.ts.map +1 -0
- package/dist/reason-prompt.js +111 -0
- package/dist/server-error.d.ts.map +1 -1
- package/dist/server-error.js +19 -0
- package/dist/use-addon-settings.d.ts +17 -3
- package/dist/use-addon-settings.d.ts.map +1 -1
- package/dist/use-addon-settings.js +20 -8
- package/package.json +1 -1
- package/src/__tests__/approval-gate.test.tsx +134 -0
- package/src/__tests__/prefill-from-record.test.ts +69 -0
- package/src/__tests__/reason-prompt.test.tsx +121 -0
- package/src/__tests__/use-addon-settings.test.ts +16 -1
- package/src/action-modal-dispatcher.tsx +145 -3
- package/src/activity-diff.tsx +10 -1
- package/src/approval-events.ts +39 -0
- package/src/approval-gate.tsx +180 -0
- package/src/approval-pin-dialog.tsx +162 -0
- package/src/business/business.test.ts +210 -0
- package/src/business/catalog.ts +68 -0
- package/src/business/customer-picker.tsx +201 -0
- package/src/business/feedback.tsx +141 -0
- package/src/business/field-errors.ts +92 -0
- package/src/business/format.ts +151 -0
- package/src/business/index.ts +15 -0
- package/src/business/line-items-editor.tsx +259 -0
- package/src/business/line-items.ts +190 -0
- package/src/business/payment-capture.tsx +158 -0
- package/src/business/payment.ts +125 -0
- package/src/business/product-picker.tsx +171 -0
- package/src/business/product-search.ts +108 -0
- package/src/business/refund-destination.tsx +121 -0
- package/src/business/refund.ts +72 -0
- package/src/business/return-wizard.tsx +223 -0
- package/src/business/return.ts +181 -0
- package/src/business/use-async-search.ts +43 -0
- package/src/document/__tests__/document-page.test.tsx +107 -0
- package/src/document/__tests__/evaluate.test.ts +140 -0
- package/src/document/action-bar.tsx +140 -0
- package/src/document/cancel-with-reason.tsx +214 -0
- package/src/document/document-header.tsx +140 -0
- package/src/document/document-page.tsx +268 -0
- package/src/document/document-timeline.tsx +66 -0
- package/src/document/evaluate.ts +180 -0
- package/src/document/format.ts +59 -0
- package/src/document/index.ts +28 -0
- package/src/document/registry.ts +39 -0
- package/src/document/smart-buttons.tsx +54 -0
- package/src/document/status-badge.tsx +44 -0
- package/src/document/types.ts +203 -0
- package/src/document/use-document-sources.ts +91 -0
- package/src/dynamic-columns.tsx +5 -1
- package/src/dynamic-relation.tsx +14 -1
- package/src/dynamic-row-actions.tsx +13 -1
- package/src/dynamic-table.tsx +22 -2
- package/src/index.ts +33 -0
- package/src/reason-prompt.tsx +247 -0
- package/src/server-error.ts +20 -0
- 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
|
+
}
|