@asteby/metacore-runtime-react 46.4.0 → 47.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/action-modal-dispatcher.d.ts +10 -1
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +107 -19
  5. package/dist/audit-info.d.ts +54 -0
  6. package/dist/audit-info.d.ts.map +1 -0
  7. package/dist/audit-info.js +142 -0
  8. package/dist/business/catalog.d.ts.map +1 -1
  9. package/dist/business/catalog.js +24 -0
  10. package/dist/business/index.d.ts +3 -0
  11. package/dist/business/index.d.ts.map +1 -1
  12. package/dist/business/index.js +3 -0
  13. package/dist/business/print-send-dialog.d.ts +27 -0
  14. package/dist/business/print-send-dialog.d.ts.map +1 -0
  15. package/dist/business/print-send-dialog.js +55 -0
  16. package/dist/business/relate-documents.d.ts +41 -0
  17. package/dist/business/relate-documents.d.ts.map +1 -0
  18. package/dist/business/relate-documents.js +42 -0
  19. package/dist/business/vehicle-picker.d.ts +36 -0
  20. package/dist/business/vehicle-picker.d.ts.map +1 -0
  21. package/dist/business/vehicle-picker.js +61 -0
  22. package/dist/cfdi-stamp-panel.d.ts +29 -0
  23. package/dist/cfdi-stamp-panel.d.ts.map +1 -0
  24. package/dist/cfdi-stamp-panel.js +53 -0
  25. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  26. package/dist/dialogs/dynamic-record.js +10 -1
  27. package/dist/document-form-dialog.d.ts +23 -0
  28. package/dist/document-form-dialog.d.ts.map +1 -0
  29. package/dist/document-form-dialog.js +196 -0
  30. package/dist/dynamic-crud-page.d.ts +6 -0
  31. package/dist/dynamic-crud-page.d.ts.map +1 -1
  32. package/dist/dynamic-crud-page.js +4 -2
  33. package/dist/dynamic-form.d.ts.map +1 -1
  34. package/dist/dynamic-form.js +3 -0
  35. package/dist/dynamic-kanban.d.ts.map +1 -1
  36. package/dist/dynamic-kanban.js +11 -2
  37. package/dist/dynamic-line-items.d.ts.map +1 -1
  38. package/dist/dynamic-line-items.js +3 -0
  39. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  40. package/dist/dynamic-multi-select-field.js +2 -0
  41. package/dist/dynamic-select-field.d.ts.map +1 -1
  42. package/dist/dynamic-select-field.js +4 -0
  43. package/dist/dynamic-table.d.ts.map +1 -1
  44. package/dist/dynamic-table.js +6 -2
  45. package/dist/index.d.ts +8 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +9 -0
  48. package/dist/nav-permissions.d.ts +19 -0
  49. package/dist/nav-permissions.d.ts.map +1 -0
  50. package/dist/nav-permissions.js +51 -0
  51. package/dist/navigation-builder.d.ts +6 -0
  52. package/dist/navigation-builder.d.ts.map +1 -1
  53. package/dist/navigation-builder.js +26 -1
  54. package/dist/option-filter.d.ts +31 -0
  55. package/dist/option-filter.d.ts.map +1 -0
  56. package/dist/option-filter.js +49 -0
  57. package/dist/permissions-manager.d.ts +9 -0
  58. package/dist/permissions-manager.d.ts.map +1 -1
  59. package/dist/permissions-manager.js +50 -17
  60. package/dist/print-document-button.d.ts.map +1 -1
  61. package/dist/print-document-button.js +9 -1
  62. package/dist/record-mutation-events.d.ts +14 -0
  63. package/dist/record-mutation-events.d.ts.map +1 -0
  64. package/dist/record-mutation-events.js +37 -0
  65. package/dist/types.d.ts +86 -0
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/use-options-resolver.d.ts +8 -0
  68. package/dist/use-options-resolver.d.ts.map +1 -1
  69. package/dist/use-options-resolver.js +8 -2
  70. package/dist/use-print-document.d.ts +15 -1
  71. package/dist/use-print-document.d.ts.map +1 -1
  72. package/dist/use-print-document.js +52 -1
  73. package/dist/use-record-mutation-tick.d.ts +6 -0
  74. package/dist/use-record-mutation-tick.d.ts.map +1 -0
  75. package/dist/use-record-mutation-tick.js +11 -0
  76. package/package.json +3 -3
  77. package/src/__tests__/action-modal-feedback.test.tsx +116 -0
  78. package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
  79. package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
  80. package/src/__tests__/audit-info.test.tsx +103 -0
  81. package/src/__tests__/business-pickers.test.tsx +157 -0
  82. package/src/__tests__/dependent-options.test.tsx +12 -5
  83. package/src/__tests__/document-form-dialog.test.tsx +190 -0
  84. package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
  85. package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
  86. package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
  87. package/src/__tests__/nav-permissions.test.tsx +76 -0
  88. package/src/__tests__/option-filter.test.tsx +115 -0
  89. package/src/__tests__/permissions-manager.test.tsx +21 -4
  90. package/src/__tests__/structured-view-value.test.tsx +5 -1
  91. package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
  92. package/src/__tests__/wizard-action-modal.test.tsx +1 -1
  93. package/src/action-modal-dispatcher.tsx +124 -6
  94. package/src/audit-info.tsx +219 -0
  95. package/src/business/catalog.ts +24 -0
  96. package/src/business/index.ts +3 -0
  97. package/src/business/print-send-dialog.tsx +175 -0
  98. package/src/business/relate-documents.tsx +182 -0
  99. package/src/business/vehicle-picker.tsx +188 -0
  100. package/src/cfdi-stamp-panel.tsx +146 -0
  101. package/src/dialogs/dynamic-record.tsx +18 -0
  102. package/src/document-form-dialog.tsx +357 -0
  103. package/src/dropdown-menu-entry.test.ts +23 -0
  104. package/src/dynamic-crud-page.tsx +21 -2
  105. package/src/dynamic-form.tsx +3 -0
  106. package/src/dynamic-kanban.tsx +10 -4
  107. package/src/dynamic-line-items.tsx +3 -0
  108. package/src/dynamic-multi-select-field.tsx +2 -0
  109. package/src/dynamic-select-field.tsx +4 -0
  110. package/src/dynamic-table.tsx +6 -2
  111. package/src/index.ts +41 -0
  112. package/src/nav-permissions.ts +58 -0
  113. package/src/navigation-builder.tsx +27 -1
  114. package/src/option-filter.ts +88 -0
  115. package/src/permissions-manager.tsx +39 -26
  116. package/src/print-document-button.tsx +9 -1
  117. package/src/record-mutation-events.ts +50 -0
  118. package/src/types.ts +90 -0
  119. package/src/use-options-resolver.ts +19 -1
  120. package/src/use-print-document.ts +66 -3
  121. package/src/use-record-mutation-tick.ts +12 -0
  122. package/vitest.config.ts +1 -0
  123. package/vitest.setup.ts +7 -0
@@ -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'
@@ -59,6 +60,8 @@ import { toastServerError, extractFieldErrors, localizeFieldIssue, localizeField
59
60
  import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
60
61
  import { DynamicMultiSelectField } from '../dynamic-multi-select-field'
61
62
  import { DynamicRelations } from '../dynamic-relations'
63
+ import { AuditInfo, readAuditMeta } from '../audit-info'
64
+ import type { AuditMeta } from '../types'
62
65
  import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
63
66
  import { getFieldRef, getVisibleWhen, evaluateVisibleWhen, ATTRIBUTE_CLASSES_KEY } from '../dynamic-form-schema'
64
67
  import { useAttributeClasses, type AttributeClass } from '../attribute-classes'
@@ -642,6 +645,7 @@ export function DynamicRecordDialog({
642
645
  schema ? (schema as ModalMetadata) : null,
643
646
  )
644
647
  const [relations, setRelations] = useState<RelationMeta[]>([])
648
+ const [audit, setAudit] = useState<AuditMeta | undefined>(undefined)
645
649
  const [record, setRecord] = useState<any | null>(null)
646
650
  const [formValues, setFormValues] = useState<Record<string, any>>({})
647
651
  // Classes of the record's category (plus those its saved data carries), for
@@ -783,6 +787,7 @@ export function DynamicRecordDialog({
783
787
  useEffect(() => {
784
788
  if (!open || mode === 'create' || !recordId) {
785
789
  setRelations([])
790
+ setAudit(undefined)
786
791
  return
787
792
  }
788
793
  let cancelled = false
@@ -791,6 +796,7 @@ export function DynamicRecordDialog({
791
796
  if (cancelled) return
792
797
  const meta = res.data?.data ?? res.data
793
798
  const rels: RelationMeta[] = Array.isArray(meta?.relations) ? meta.relations : []
799
+ setAudit(readAuditMeta(meta?.audit))
794
800
  // Localize each panel header: the backend serves `label` as an
795
801
  // i18n key (addon bundle, loaded live) and the SDK renders it verbatim.
796
802
  setRelations(
@@ -945,6 +951,7 @@ export function DynamicRecordDialog({
945
951
  if (isCreate && onCreate) {
946
952
  const created = await onCreate(payload)
947
953
  toast.success(modalMeta?.messages?.created || t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }))
954
+ emitRecordMutation(model, 'create')
948
955
  onSaved?.(created ?? undefined)
949
956
  onOpenChange(false)
950
957
  return
@@ -953,6 +960,7 @@ export function DynamicRecordDialog({
953
960
  if (!isCreate && recordId && onUpdate) {
954
961
  const updated = await onUpdate(String(recordId), payload)
955
962
  toast.success(modalMeta?.messages?.updated || t('dynamic.update_success', { defaultValue: 'Guardado correctamente' }))
963
+ emitRecordMutation(model, 'update')
956
964
  onSaved?.(updated ?? undefined)
957
965
  onOpenChange(false)
958
966
  return
@@ -980,6 +988,7 @@ export function DynamicRecordDialog({
980
988
  : t('dynamic.update_success', { defaultValue: 'Guardado correctamente' })),
981
989
  )
982
990
  // Hand the persisted record back so callers can auto-select it.
991
+ emitRecordMutation(model, isCreate ? 'create' : 'update')
983
992
  onSaved?.(res.data?.data ?? res.data ?? undefined)
984
993
  onOpenChange(false)
985
994
  } else {
@@ -1000,6 +1009,7 @@ export function DynamicRecordDialog({
1000
1009
  setDeleting(true)
1001
1010
  try {
1002
1011
  await onDelete()
1012
+ emitRecordMutation(model, 'delete')
1003
1013
  onOpenChange(false)
1004
1014
  } catch (err: any) {
1005
1015
  console.error('[DynamicRecordDialog] delete error:', err)
@@ -1184,6 +1194,14 @@ export function DynamicRecordDialog({
1184
1194
  />
1185
1195
  </div>
1186
1196
  )}
1197
+ {!isCreate && record && audit && (
1198
+ <AuditInfo
1199
+ record={record}
1200
+ audit={audit}
1201
+ timeZone={timeZone}
1202
+ className="mt-6 border-t pt-3"
1203
+ />
1204
+ )}
1187
1205
  </CurrencyContext.Provider>
1188
1206
  </TimeZoneContext.Provider>
1189
1207
  </ImageUrlContext.Provider>