@asteby/metacore-runtime-react 44.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.
- package/CHANGELOG.md +28 -0
- 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 +42 -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 +14 -0
- package/dist/business/index.d.ts.map +1 -0
- package/dist/business/index.js +13 -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 +43 -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 +11 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +13 -1
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +4 -5
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +12 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +16 -3
- package/dist/requires-addon.d.ts +37 -0
- package/dist/requires-addon.d.ts.map +1 -0
- package/dist/requires-addon.js +104 -0
- package/dist/server-error.d.ts.map +1 -1
- package/dist/server-error.js +19 -0
- package/dist/types.d.ts +23 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/approval-gate.test.tsx +134 -0
- package/src/__tests__/requires-addon.test.tsx +201 -0
- 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 +164 -0
- package/src/business/catalog.ts +60 -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 +13 -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 +71 -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 +36 -6
- package/src/dynamic-kanban.tsx +6 -7
- package/src/dynamic-row-actions.tsx +15 -1
- package/src/index.ts +34 -0
- package/src/model-action-toolbar.tsx +28 -3
- package/src/requires-addon.tsx +160 -0
- package/src/server-error.ts +20 -0
- package/src/types.ts +24 -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 }
|