@asteby/metacore-runtime-react 46.4.0 → 47.0.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 +25 -0
- package/dist/action-modal-dispatcher.d.ts +10 -1
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +107 -19
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +24 -0
- package/dist/business/index.d.ts +3 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +3 -0
- package/dist/business/print-send-dialog.d.ts +27 -0
- package/dist/business/print-send-dialog.d.ts.map +1 -0
- package/dist/business/print-send-dialog.js +55 -0
- package/dist/business/relate-documents.d.ts +41 -0
- package/dist/business/relate-documents.d.ts.map +1 -0
- package/dist/business/relate-documents.js +42 -0
- package/dist/business/vehicle-picker.d.ts +36 -0
- package/dist/business/vehicle-picker.d.ts.map +1 -0
- package/dist/business/vehicle-picker.js +61 -0
- package/dist/cfdi-stamp-panel.d.ts +29 -0
- package/dist/cfdi-stamp-panel.d.ts.map +1 -0
- package/dist/cfdi-stamp-panel.js +53 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +5 -0
- package/dist/document-form-dialog.d.ts +23 -0
- package/dist/document-form-dialog.d.ts.map +1 -0
- package/dist/document-form-dialog.js +196 -0
- package/dist/dynamic-crud-page.d.ts +6 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +4 -2
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +3 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +6 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/nav-permissions.d.ts +19 -0
- package/dist/nav-permissions.d.ts.map +1 -0
- package/dist/nav-permissions.js +51 -0
- package/dist/navigation-builder.d.ts +6 -0
- package/dist/navigation-builder.d.ts.map +1 -1
- package/dist/navigation-builder.js +26 -1
- package/dist/permissions-manager.d.ts +9 -0
- package/dist/permissions-manager.d.ts.map +1 -1
- package/dist/permissions-manager.js +50 -17
- package/dist/print-document-button.d.ts.map +1 -1
- package/dist/print-document-button.js +9 -1
- package/dist/record-mutation-events.d.ts +14 -0
- package/dist/record-mutation-events.d.ts.map +1 -0
- package/dist/record-mutation-events.js +37 -0
- package/dist/types.d.ts +46 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-print-document.d.ts +15 -1
- package/dist/use-print-document.d.ts.map +1 -1
- package/dist/use-print-document.js +52 -1
- package/dist/use-record-mutation-tick.d.ts +6 -0
- package/dist/use-record-mutation-tick.d.ts.map +1 -0
- package/dist/use-record-mutation-tick.js +11 -0
- package/package.json +3 -3
- package/src/__tests__/action-modal-feedback.test.tsx +116 -0
- package/src/__tests__/business-pickers.test.tsx +157 -0
- package/src/__tests__/document-form-dialog.test.tsx +190 -0
- package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
- package/src/__tests__/nav-permissions.test.tsx +76 -0
- package/src/__tests__/permissions-manager.test.tsx +21 -4
- package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
- package/src/action-modal-dispatcher.tsx +124 -6
- package/src/business/catalog.ts +24 -0
- package/src/business/index.ts +3 -0
- package/src/business/print-send-dialog.tsx +175 -0
- package/src/business/relate-documents.tsx +182 -0
- package/src/business/vehicle-picker.tsx +188 -0
- package/src/cfdi-stamp-panel.tsx +146 -0
- package/src/dialogs/dynamic-record.tsx +5 -0
- package/src/document-form-dialog.tsx +357 -0
- package/src/dynamic-crud-page.tsx +21 -2
- package/src/dynamic-kanban.tsx +3 -1
- package/src/dynamic-table.tsx +6 -2
- package/src/index.ts +28 -0
- package/src/nav-permissions.ts +58 -0
- package/src/navigation-builder.tsx +27 -1
- package/src/permissions-manager.tsx +39 -26
- package/src/print-document-button.tsx +9 -1
- package/src/record-mutation-events.ts +50 -0
- package/src/types.ts +49 -0
- package/src/use-print-document.ts +66 -3
- package/src/use-record-mutation-tick.ts +12 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// RelateDocuments — relaciona uno o más documentos origen (CFDI) desde un
|
|
2
|
+
// formulario: nota de crédito, devolución, sustitución. El valor es DATO
|
|
3
|
+
// (`related_document_id`, `relation_type`, UUID/folio para mostrar), no un flujo
|
|
4
|
+
// de un solo módulo. El SDK no embebe catálogos fiscales: los tipos de relación
|
|
5
|
+
// (p. ej. SAT 01/03/04) llegan por la prop `relationTypes`.
|
|
6
|
+
import { useState } from 'react'
|
|
7
|
+
import { useTranslation } from 'react-i18next'
|
|
8
|
+
import { Link2, Search, X } from 'lucide-react'
|
|
9
|
+
import { Badge, Button, Input } from '@asteby/metacore-ui'
|
|
10
|
+
import { useAsyncSearch } from './use-async-search'
|
|
11
|
+
|
|
12
|
+
export interface RelatableDocument {
|
|
13
|
+
id: string
|
|
14
|
+
/** UUID fiscal (CFDI), cuando ya está timbrado. */
|
|
15
|
+
uuid?: string
|
|
16
|
+
folio?: string
|
|
17
|
+
/** Texto libre para mostrar («Factura A-120 · Cliente X»). */
|
|
18
|
+
label?: string
|
|
19
|
+
total?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RelationTypeOption {
|
|
23
|
+
/** Clave que viaja en `relation_type` (p. ej. `"01"`). */
|
|
24
|
+
value: string
|
|
25
|
+
label: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Un documento relacionado: lo que el formulario guarda. */
|
|
29
|
+
export interface RelatedDocument {
|
|
30
|
+
related_document_id: string
|
|
31
|
+
relation_type: string
|
|
32
|
+
/** Solo para mostrar. */
|
|
33
|
+
uuid?: string
|
|
34
|
+
folio?: string
|
|
35
|
+
label?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RelateDocumentsProps {
|
|
39
|
+
value: RelatedDocument[]
|
|
40
|
+
onChange: (value: RelatedDocument[]) => void
|
|
41
|
+
/** Catálogo de tipos de relación (lo aporta el addon). */
|
|
42
|
+
relationTypes: RelationTypeOption[]
|
|
43
|
+
/** Búsqueda de documentos origen. Estable (useCallback). */
|
|
44
|
+
search: (q: string, signal: AbortSignal) => Promise<RelatableDocument[]>
|
|
45
|
+
/** Tipo preseleccionado al agregar. Default: el primero del catálogo. */
|
|
46
|
+
defaultRelationType?: string
|
|
47
|
+
/** Máximo de documentos relacionados. Sin tope por defecto. */
|
|
48
|
+
max?: number
|
|
49
|
+
disabled?: boolean
|
|
50
|
+
placeholder?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function docTitle(d: { folio?: string; label?: string; uuid?: string; id?: string }): string {
|
|
54
|
+
return d.label || d.folio || d.uuid || d.id || ''
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function RelateDocuments({
|
|
58
|
+
value,
|
|
59
|
+
onChange,
|
|
60
|
+
relationTypes,
|
|
61
|
+
search,
|
|
62
|
+
defaultRelationType,
|
|
63
|
+
max,
|
|
64
|
+
disabled,
|
|
65
|
+
placeholder,
|
|
66
|
+
}: RelateDocumentsProps) {
|
|
67
|
+
const { t } = useTranslation()
|
|
68
|
+
const [text, setText] = useState('')
|
|
69
|
+
const [pendingType, setPendingType] = useState<string>(defaultRelationType ?? relationTypes[0]?.value ?? '')
|
|
70
|
+
const { results, loading, error } = useAsyncSearch(text, search, { minChars: 2 })
|
|
71
|
+
const full = max != null && value.length >= max
|
|
72
|
+
const typeLabel = (v: string) => relationTypes.find((r) => r.value === v)?.label ?? v
|
|
73
|
+
|
|
74
|
+
const add = (d: RelatableDocument) => {
|
|
75
|
+
if (full || value.some((x) => x.related_document_id === d.id)) return
|
|
76
|
+
onChange([
|
|
77
|
+
...value,
|
|
78
|
+
{ related_document_id: d.id, relation_type: pendingType, uuid: d.uuid, folio: d.folio, label: d.label },
|
|
79
|
+
])
|
|
80
|
+
setText('')
|
|
81
|
+
}
|
|
82
|
+
const remove = (id: string) => onChange(value.filter((x) => x.related_document_id !== id))
|
|
83
|
+
const setType = (id: string, relation_type: string) =>
|
|
84
|
+
onChange(value.map((x) => (x.related_document_id === id ? { ...x, relation_type } : x)))
|
|
85
|
+
|
|
86
|
+
const selectCls = 'h-9 rounded-md border bg-background px-2 text-sm'
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div data-slot="relate-documents" className="space-y-2">
|
|
90
|
+
{value.length > 0 && (
|
|
91
|
+
<ul className="divide-y rounded-md border">
|
|
92
|
+
{value.map((d) => (
|
|
93
|
+
<li key={d.related_document_id} className="flex flex-wrap items-center gap-2 px-2 py-1.5">
|
|
94
|
+
<Link2 className="size-4 text-muted-foreground" aria-hidden />
|
|
95
|
+
<div className="min-w-0 flex-1">
|
|
96
|
+
<p className="truncate text-sm font-medium">{docTitle({ folio: d.folio, label: d.label, id: d.related_document_id })}</p>
|
|
97
|
+
{d.uuid && <p className="break-all font-mono text-xs text-muted-foreground">{d.uuid}</p>}
|
|
98
|
+
</div>
|
|
99
|
+
<select
|
|
100
|
+
className={selectCls}
|
|
101
|
+
disabled={disabled}
|
|
102
|
+
value={d.relation_type}
|
|
103
|
+
aria-label={t('relateDocuments.type', { defaultValue: 'Tipo de relación' })}
|
|
104
|
+
onChange={(e) => setType(d.related_document_id, e.target.value)}
|
|
105
|
+
>
|
|
106
|
+
{!relationTypes.some((r) => r.value === d.relation_type) && (
|
|
107
|
+
<option value={d.relation_type}>{typeLabel(d.relation_type)}</option>
|
|
108
|
+
)}
|
|
109
|
+
{relationTypes.map((r) => (
|
|
110
|
+
<option key={r.value} value={r.value}>{r.label}</option>
|
|
111
|
+
))}
|
|
112
|
+
</select>
|
|
113
|
+
{!disabled && (
|
|
114
|
+
<Button type="button" size="icon" variant="ghost" onClick={() => remove(d.related_document_id)} aria-label={t('relateDocuments.remove', { defaultValue: 'Quitar relación' })}>
|
|
115
|
+
<X className="size-4" />
|
|
116
|
+
</Button>
|
|
117
|
+
)}
|
|
118
|
+
</li>
|
|
119
|
+
))}
|
|
120
|
+
</ul>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
{!full && !disabled && (
|
|
124
|
+
<div className="space-y-2">
|
|
125
|
+
<div className="flex items-center gap-1.5">
|
|
126
|
+
<div className="relative flex-1">
|
|
127
|
+
<Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden />
|
|
128
|
+
<Input
|
|
129
|
+
className="pl-8"
|
|
130
|
+
value={text}
|
|
131
|
+
placeholder={placeholder ?? t('relateDocuments.placeholder', { defaultValue: 'Buscar por folio o UUID' })}
|
|
132
|
+
onChange={(e) => setText(e.target.value)}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
<select
|
|
136
|
+
className={selectCls}
|
|
137
|
+
value={pendingType}
|
|
138
|
+
aria-label={t('relateDocuments.newType', { defaultValue: 'Tipo para el documento nuevo' })}
|
|
139
|
+
onChange={(e) => setPendingType(e.target.value)}
|
|
140
|
+
>
|
|
141
|
+
{relationTypes.map((r) => (
|
|
142
|
+
<option key={r.value} value={r.value}>{r.label}</option>
|
|
143
|
+
))}
|
|
144
|
+
</select>
|
|
145
|
+
</div>
|
|
146
|
+
{loading && <p className="text-sm text-muted-foreground">{t('common.searching', { defaultValue: 'Buscando…' })}</p>}
|
|
147
|
+
{error && !loading && (
|
|
148
|
+
<p role="alert" className="text-sm text-destructive">
|
|
149
|
+
{t('relateDocuments.error', { defaultValue: 'No se pudo buscar documentos. Inténtalo de nuevo.' })}
|
|
150
|
+
</p>
|
|
151
|
+
)}
|
|
152
|
+
{!loading && !error && text.trim().length >= 2 && results.length === 0 && (
|
|
153
|
+
<p className="text-sm text-muted-foreground">{t('relateDocuments.empty', { defaultValue: 'No encontramos ese documento.' })}</p>
|
|
154
|
+
)}
|
|
155
|
+
{results.length > 0 && (
|
|
156
|
+
<ul role="listbox" className="max-h-64 divide-y overflow-auto rounded-md border">
|
|
157
|
+
{results.map((d) => {
|
|
158
|
+
const already = value.some((x) => x.related_document_id === d.id)
|
|
159
|
+
return (
|
|
160
|
+
<li key={d.id} role="option" aria-selected={already}>
|
|
161
|
+
<button type="button" disabled={already} className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-accent disabled:opacity-50" onClick={() => add(d)}>
|
|
162
|
+
<span>
|
|
163
|
+
<span className="block text-sm font-medium">{docTitle(d)}</span>
|
|
164
|
+
{d.uuid && <span className="block break-all font-mono text-xs text-muted-foreground">{d.uuid}</span>}
|
|
165
|
+
</span>
|
|
166
|
+
{already && <Badge variant="muted">{t('relateDocuments.added', { defaultValue: 'Agregado' })}</Badge>}
|
|
167
|
+
</button>
|
|
168
|
+
</li>
|
|
169
|
+
)
|
|
170
|
+
})}
|
|
171
|
+
</ul>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Valor a enviar al servidor: solo los datos, sin los campos de visualización. */
|
|
180
|
+
export function serializeRelatedDocuments(value: RelatedDocument[]): Array<Pick<RelatedDocument, 'related_document_id' | 'relation_type'>> {
|
|
181
|
+
return value.map(({ related_document_id, relation_type }) => ({ related_document_id, relation_type }))
|
|
182
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// VehiclePicker + alta rápida — busca por placa o VIN (y marca/modelo/cliente que
|
|
2
|
+
// el host decida indexar) y reutiliza el <CreateRecordDialog> dinámico del modelo
|
|
3
|
+
// vehículo para el alta rápida. Mismo contrato que <CustomerPicker>: controlado
|
|
4
|
+
// (`value` / `onChange`), `search` inyectado por el host, permisos con useCan
|
|
5
|
+
// (sin <PermissionsProvider> no bloquea nada: el backend autoriza).
|
|
6
|
+
import { useCallback, useState } from 'react'
|
|
7
|
+
import { useTranslation } from 'react-i18next'
|
|
8
|
+
import { Car, Plus, Search, X } from 'lucide-react'
|
|
9
|
+
import { Button, Input } from '@asteby/metacore-ui'
|
|
10
|
+
import { CreateRecordDialog } from '../dialogs/create-record-dialog'
|
|
11
|
+
import { useApi } from '../api-context'
|
|
12
|
+
import { useCan } from '../permissions-context'
|
|
13
|
+
import { useAsyncSearch } from './use-async-search'
|
|
14
|
+
import { EmptyState } from './feedback'
|
|
15
|
+
|
|
16
|
+
export interface VehicleResult {
|
|
17
|
+
id: string
|
|
18
|
+
/** Placa tal como la capturó el taller (sin formato hardcodeado por país). */
|
|
19
|
+
plate?: string
|
|
20
|
+
vin?: string
|
|
21
|
+
make?: string
|
|
22
|
+
model?: string
|
|
23
|
+
year?: number | string
|
|
24
|
+
color?: string
|
|
25
|
+
/** Propietario, cuando el host lo expone. */
|
|
26
|
+
customer_id?: string
|
|
27
|
+
customer_name?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface VehiclePickerProps {
|
|
31
|
+
value: VehicleResult | null
|
|
32
|
+
/** Evento único de cambio: vehículo elegido/creado, o `null` al limpiar. */
|
|
33
|
+
onChange: (vehicle: VehicleResult | null) => void
|
|
34
|
+
/** Búsqueda por placa o VIN. Estable (useCallback). */
|
|
35
|
+
search: (q: string, signal: AbortSignal) => Promise<VehicleResult[]>
|
|
36
|
+
/** Modelo del kernel para el alta rápida. Default `Vehicle`. */
|
|
37
|
+
model?: string
|
|
38
|
+
/** Endpoint CRUD del alta. Default `/data/<model>/me`. */
|
|
39
|
+
endpoint?: string
|
|
40
|
+
/** Convierte el registro guardado a `VehicleResult`. Default: id, placa, VIN, marca, modelo, año. */
|
|
41
|
+
mapRecord?: (rec: Record<string, unknown>) => VehicleResult
|
|
42
|
+
/** Override de permiso (default `<model>.create` con useCan). */
|
|
43
|
+
canCreate?: boolean
|
|
44
|
+
/** Valores iniciales del alta (p. ej. `customer_id` del cliente ya elegido). */
|
|
45
|
+
createDefaults?: Record<string, unknown>
|
|
46
|
+
disabled?: boolean
|
|
47
|
+
placeholder?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function snake(model: string): string {
|
|
51
|
+
return model.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
|
|
55
|
+
|
|
56
|
+
/** Línea corta de un vehículo: «Nissan Versa 2019 · ABC-123 · VIN…». */
|
|
57
|
+
export function describeVehicle(v: VehicleResult): string {
|
|
58
|
+
const unit = [v.make, v.model, v.year].filter((x) => x != null && x !== '').join(' ')
|
|
59
|
+
return [unit, v.plate, v.vin].filter(Boolean).join(' · ')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function VehiclePicker({
|
|
63
|
+
value,
|
|
64
|
+
onChange,
|
|
65
|
+
search,
|
|
66
|
+
model = 'Vehicle',
|
|
67
|
+
endpoint,
|
|
68
|
+
mapRecord,
|
|
69
|
+
canCreate,
|
|
70
|
+
createDefaults,
|
|
71
|
+
disabled,
|
|
72
|
+
placeholder,
|
|
73
|
+
}: VehiclePickerProps) {
|
|
74
|
+
const { t } = useTranslation()
|
|
75
|
+
const api = useApi()
|
|
76
|
+
const can = useCan()
|
|
77
|
+
const [text, setText] = useState('')
|
|
78
|
+
const [creating, setCreating] = useState(false)
|
|
79
|
+
const { results, loading, error } = useAsyncSearch(text, search, { minChars: 2 })
|
|
80
|
+
const mayCreate = canCreate ?? can(`${snake(model)}.create`)
|
|
81
|
+
const base = endpoint ?? `/data/${model}/me`
|
|
82
|
+
|
|
83
|
+
const pick = (v: VehicleResult) => {
|
|
84
|
+
onChange(v)
|
|
85
|
+
setText('')
|
|
86
|
+
}
|
|
87
|
+
const toResult = useCallback(
|
|
88
|
+
(rec: Record<string, unknown>): VehicleResult =>
|
|
89
|
+
mapRecord
|
|
90
|
+
? mapRecord(rec)
|
|
91
|
+
: {
|
|
92
|
+
id: String(rec.id ?? ''),
|
|
93
|
+
plate: str(rec.plate),
|
|
94
|
+
vin: str(rec.vin),
|
|
95
|
+
make: str(rec.make),
|
|
96
|
+
model: str(rec.model),
|
|
97
|
+
year: typeof rec.year === 'number' || typeof rec.year === 'string' ? rec.year : undefined,
|
|
98
|
+
color: str(rec.color),
|
|
99
|
+
customer_id: str(rec.customer_id),
|
|
100
|
+
},
|
|
101
|
+
[mapRecord],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if (value) {
|
|
105
|
+
return (
|
|
106
|
+
<div data-slot="vehicle-picker" className="flex items-start justify-between gap-2 rounded-md border p-2">
|
|
107
|
+
<div className="flex items-start gap-2">
|
|
108
|
+
<Car className="mt-0.5 size-4 text-muted-foreground" aria-hidden />
|
|
109
|
+
<div>
|
|
110
|
+
<p className="text-sm font-medium">{value.plate || value.vin || t('vehiclePicker.noPlate', { defaultValue: 'Sin placa' })}</p>
|
|
111
|
+
<p className="text-xs text-muted-foreground">
|
|
112
|
+
{[[value.make, value.model, value.year].filter((x) => x != null && x !== '').join(' '), value.vin && value.plate ? value.vin : '', value.customer_name]
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.join(' · ')}
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
{!disabled && (
|
|
119
|
+
<Button type="button" size="icon" variant="ghost" onClick={() => onChange(null)} aria-label={t('vehiclePicker.clear', { defaultValue: 'Quitar vehículo' })}>
|
|
120
|
+
<X className="size-4" />
|
|
121
|
+
</Button>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div data-slot="vehicle-picker" className="space-y-2">
|
|
129
|
+
<div className="flex items-center gap-1.5">
|
|
130
|
+
<div className="relative flex-1">
|
|
131
|
+
<Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden />
|
|
132
|
+
<Input
|
|
133
|
+
className="pl-8"
|
|
134
|
+
disabled={disabled}
|
|
135
|
+
value={text}
|
|
136
|
+
placeholder={placeholder ?? t('vehiclePicker.placeholder', { defaultValue: 'Placa o VIN' })}
|
|
137
|
+
onChange={(e) => setText(e.target.value)}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
{mayCreate && (
|
|
141
|
+
<Button type="button" variant="outline" size="icon" disabled={disabled} onClick={() => setCreating(true)} aria-label={t('vehiclePicker.create', { defaultValue: 'Nuevo vehículo' })}>
|
|
142
|
+
<Plus className="size-4" />
|
|
143
|
+
</Button>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
{loading && <p className="text-sm text-muted-foreground">{t('common.searching', { defaultValue: 'Buscando…' })}</p>}
|
|
147
|
+
{error && !loading && (
|
|
148
|
+
<p role="alert" className="text-sm text-destructive">
|
|
149
|
+
{t('vehiclePicker.error', { defaultValue: 'No se pudo buscar vehículos. Inténtalo de nuevo.' })}
|
|
150
|
+
</p>
|
|
151
|
+
)}
|
|
152
|
+
{!loading && !error && text.trim().length >= 2 && results.length === 0 && (
|
|
153
|
+
<EmptyState
|
|
154
|
+
title={t('vehiclePicker.empty', { defaultValue: 'No encontramos ese vehículo' })}
|
|
155
|
+
action={mayCreate ? { label: t('vehiclePicker.createAction', { defaultValue: 'Crear vehículo' }), onClick: () => setCreating(true) } : undefined}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
{results.length > 0 && (
|
|
159
|
+
<ul role="listbox" className="max-h-64 divide-y overflow-auto rounded-md border">
|
|
160
|
+
{results.map((v) => (
|
|
161
|
+
<li key={v.id} role="option" aria-selected={false}>
|
|
162
|
+
<button type="button" className="flex w-full flex-col px-2 py-1.5 text-left hover:bg-accent" onClick={() => pick(v)}>
|
|
163
|
+
<span className="text-sm font-medium">{v.plate || v.vin || v.id}</span>
|
|
164
|
+
<span className="text-xs text-muted-foreground">{[describeVehicle({ ...v, plate: undefined }), v.customer_name].filter(Boolean).join(' · ')}</span>
|
|
165
|
+
</button>
|
|
166
|
+
</li>
|
|
167
|
+
))}
|
|
168
|
+
</ul>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{creating && (
|
|
172
|
+
<CreateRecordDialog
|
|
173
|
+
modelKey={model}
|
|
174
|
+
open={creating}
|
|
175
|
+
onOpenChange={setCreating}
|
|
176
|
+
endpoint={base}
|
|
177
|
+
defaults={createDefaults ?? (text.trim() ? { plate: text.trim() } : undefined)}
|
|
178
|
+
onCreate={async (data) => {
|
|
179
|
+
const res = await api.post(base, data)
|
|
180
|
+
const rec = (res.data?.data ?? res.data) as Record<string, unknown>
|
|
181
|
+
pick(toResult(rec))
|
|
182
|
+
return rec.id != null ? { id: String(rec.id) } : undefined
|
|
183
|
+
}}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// CfdiStampPanel — human-readable result of stamping (timbrar) a CFDI.
|
|
2
|
+
//
|
|
3
|
+
// A stamp action answers `{ document_id, fiscal_uuid, pdf_url, xml_url }`. The
|
|
4
|
+
// dialog used to dump that JSON; this panel shows the fiscal UUID as text and
|
|
5
|
+
// the PDF / XML as download links instead (#1024, FAC-3 / PIT-051). The SDK
|
|
6
|
+
// invents nothing: only the keys the server actually returned are rendered.
|
|
7
|
+
import { useTranslation } from 'react-i18next'
|
|
8
|
+
import {
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
Button,
|
|
16
|
+
} from '@asteby/metacore-ui/primitives'
|
|
17
|
+
import { CheckCircle2, FileCode2, FileText } from 'lucide-react'
|
|
18
|
+
|
|
19
|
+
export interface CfdiStampResult {
|
|
20
|
+
fiscalUuid?: string
|
|
21
|
+
pdfUrl?: string
|
|
22
|
+
xmlUrl?: string
|
|
23
|
+
/** Optional human folio (`serie-folio`) when the server sends it. */
|
|
24
|
+
folio?: string
|
|
25
|
+
documentId?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const str = (v: unknown): string | undefined =>
|
|
29
|
+
typeof v === 'string' && v.trim() ? v.trim() : undefined
|
|
30
|
+
|
|
31
|
+
const isSafeUrl = (u: string) => /^(https?:)?\/\//i.test(u) || u.startsWith('/') || u.startsWith('blob:')
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reads a stamp result out of a successful action response body. Looks at
|
|
35
|
+
* `body.data`, `body.result` and the body itself. Returns `undefined` unless
|
|
36
|
+
* the payload carries `fiscal_uuid` and/or `pdf_url` / `xml_url` — a response
|
|
37
|
+
* without those keys is never turned into a fiscal result.
|
|
38
|
+
*/
|
|
39
|
+
export function extractStampResult(body: unknown): CfdiStampResult | undefined {
|
|
40
|
+
if (!body || typeof body !== 'object') return undefined
|
|
41
|
+
const b = body as Record<string, unknown>
|
|
42
|
+
const candidates = [b.data, b.result, b]
|
|
43
|
+
for (const c of candidates) {
|
|
44
|
+
if (!c || typeof c !== 'object' || Array.isArray(c)) continue
|
|
45
|
+
const o = c as Record<string, unknown>
|
|
46
|
+
const fiscalUuid = str(o.fiscal_uuid) ?? str(o.fiscalUuid) ?? str(o.uuid)
|
|
47
|
+
const pdf = str(o.pdf_url) ?? str(o.pdfUrl)
|
|
48
|
+
const xml = str(o.xml_url) ?? str(o.xmlUrl)
|
|
49
|
+
// A bare `uuid` is too generic to call a fiscal result on its own.
|
|
50
|
+
const hasFiscal = !!str(o.fiscal_uuid) || !!str(o.fiscalUuid) || !!pdf || !!xml
|
|
51
|
+
if (!hasFiscal) continue
|
|
52
|
+
return {
|
|
53
|
+
fiscalUuid,
|
|
54
|
+
pdfUrl: pdf && isSafeUrl(pdf) ? pdf : undefined,
|
|
55
|
+
xmlUrl: xml && isSafeUrl(xml) ? xml : undefined,
|
|
56
|
+
folio: str(o.folio) ?? str(o.number),
|
|
57
|
+
documentId: str(o.document_id) ?? str(o.documentId),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CfdiStampPanelProps {
|
|
64
|
+
result: CfdiStampResult
|
|
65
|
+
className?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function CfdiStampPanel({ result, className }: CfdiStampPanelProps) {
|
|
69
|
+
const { t } = useTranslation()
|
|
70
|
+
return (
|
|
71
|
+
<div data-slot="cfdi-stamp-panel" className={`space-y-4 ${className ?? ''}`}>
|
|
72
|
+
<div className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
|
73
|
+
<CheckCircle2 className="size-5" aria-hidden />
|
|
74
|
+
{t('cfdi.stamp.success', { defaultValue: 'CFDI timbrado correctamente' })}
|
|
75
|
+
</div>
|
|
76
|
+
<dl className="space-y-2 rounded-md border bg-muted/40 p-3 text-sm">
|
|
77
|
+
{result.folio && (
|
|
78
|
+
<div className="flex flex-col gap-0.5">
|
|
79
|
+
<dt className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
80
|
+
{t('cfdi.stamp.folio', { defaultValue: 'Folio' })}
|
|
81
|
+
</dt>
|
|
82
|
+
<dd>{result.folio}</dd>
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
{result.fiscalUuid && (
|
|
86
|
+
<div className="flex flex-col gap-0.5">
|
|
87
|
+
<dt className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
88
|
+
{t('cfdi.stamp.uuid', { defaultValue: 'UUID fiscal' })}
|
|
89
|
+
</dt>
|
|
90
|
+
<dd className="break-all font-mono" data-slot="cfdi-stamp-uuid">
|
|
91
|
+
{result.fiscalUuid}
|
|
92
|
+
</dd>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</dl>
|
|
96
|
+
{(result.pdfUrl || result.xmlUrl) && (
|
|
97
|
+
<div className="flex flex-wrap gap-2">
|
|
98
|
+
{result.pdfUrl && (
|
|
99
|
+
<Button asChild variant="outline" size="sm">
|
|
100
|
+
<a href={result.pdfUrl} target="_blank" rel="noopener noreferrer" data-slot="cfdi-stamp-pdf">
|
|
101
|
+
<FileText className="mr-2 size-4" aria-hidden />
|
|
102
|
+
{t('cfdi.stamp.pdf', { defaultValue: 'Descargar PDF' })}
|
|
103
|
+
</a>
|
|
104
|
+
</Button>
|
|
105
|
+
)}
|
|
106
|
+
{result.xmlUrl && (
|
|
107
|
+
<Button asChild variant="outline" size="sm">
|
|
108
|
+
<a href={result.xmlUrl} target="_blank" rel="noopener noreferrer" data-slot="cfdi-stamp-xml">
|
|
109
|
+
<FileCode2 className="mr-2 size-4" aria-hidden />
|
|
110
|
+
{t('cfdi.stamp.xml', { defaultValue: 'Descargar XML' })}
|
|
111
|
+
</a>
|
|
112
|
+
</Button>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface CfdiStampResultDialogProps {
|
|
121
|
+
open: boolean
|
|
122
|
+
onOpenChange: (open: boolean) => void
|
|
123
|
+
result: CfdiStampResult
|
|
124
|
+
title?: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Dialog shell used by the action modals after a successful stamp. */
|
|
128
|
+
export function CfdiStampResultDialog({ open, onOpenChange, result, title }: CfdiStampResultDialogProps) {
|
|
129
|
+
const { t } = useTranslation()
|
|
130
|
+
return (
|
|
131
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
132
|
+
<DialogContent className="sm:max-w-md">
|
|
133
|
+
<DialogHeader>
|
|
134
|
+
<DialogTitle>{title ?? t('cfdi.stamp.title', { defaultValue: 'Resultado del timbrado' })}</DialogTitle>
|
|
135
|
+
<DialogDescription className="sr-only">
|
|
136
|
+
{t('cfdi.stamp.desc', { defaultValue: 'UUID fiscal y archivos del CFDI timbrado' })}
|
|
137
|
+
</DialogDescription>
|
|
138
|
+
</DialogHeader>
|
|
139
|
+
<CfdiStampPanel result={result} />
|
|
140
|
+
<DialogFooter>
|
|
141
|
+
<Button onClick={() => onOpenChange(false)}>{t('common.close', { defaultValue: 'Cerrar' })}</Button>
|
|
142
|
+
</DialogFooter>
|
|
143
|
+
</DialogContent>
|
|
144
|
+
</Dialog>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// flows through <ApiProvider> from runtime-react. Host-specific runtime values —
|
|
10
10
|
// the image-url resolver and the org IANA timezone — are passed as props so the
|
|
11
11
|
// SDK stays transport- and host-agnostic.
|
|
12
|
+
import { emitRecordMutation } from '../record-mutation-events'
|
|
12
13
|
import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from 'react'
|
|
13
14
|
import { useTranslation } from 'react-i18next'
|
|
14
15
|
import type { ModelSchema } from './types'
|
|
@@ -945,6 +946,7 @@ export function DynamicRecordDialog({
|
|
|
945
946
|
if (isCreate && onCreate) {
|
|
946
947
|
const created = await onCreate(payload)
|
|
947
948
|
toast.success(modalMeta?.messages?.created || t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }))
|
|
949
|
+
emitRecordMutation(model, 'create')
|
|
948
950
|
onSaved?.(created ?? undefined)
|
|
949
951
|
onOpenChange(false)
|
|
950
952
|
return
|
|
@@ -953,6 +955,7 @@ export function DynamicRecordDialog({
|
|
|
953
955
|
if (!isCreate && recordId && onUpdate) {
|
|
954
956
|
const updated = await onUpdate(String(recordId), payload)
|
|
955
957
|
toast.success(modalMeta?.messages?.updated || t('dynamic.update_success', { defaultValue: 'Guardado correctamente' }))
|
|
958
|
+
emitRecordMutation(model, 'update')
|
|
956
959
|
onSaved?.(updated ?? undefined)
|
|
957
960
|
onOpenChange(false)
|
|
958
961
|
return
|
|
@@ -980,6 +983,7 @@ export function DynamicRecordDialog({
|
|
|
980
983
|
: t('dynamic.update_success', { defaultValue: 'Guardado correctamente' })),
|
|
981
984
|
)
|
|
982
985
|
// Hand the persisted record back so callers can auto-select it.
|
|
986
|
+
emitRecordMutation(model, isCreate ? 'create' : 'update')
|
|
983
987
|
onSaved?.(res.data?.data ?? res.data ?? undefined)
|
|
984
988
|
onOpenChange(false)
|
|
985
989
|
} else {
|
|
@@ -1000,6 +1004,7 @@ export function DynamicRecordDialog({
|
|
|
1000
1004
|
setDeleting(true)
|
|
1001
1005
|
try {
|
|
1002
1006
|
await onDelete()
|
|
1007
|
+
emitRecordMutation(model, 'delete')
|
|
1003
1008
|
onOpenChange(false)
|
|
1004
1009
|
} catch (err: any) {
|
|
1005
1010
|
console.error('[DynamicRecordDialog] delete error:', err)
|