@asteby/metacore-runtime-react 46.3.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +31 -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/business/catalog.d.ts.map +1 -1
  6. package/dist/business/catalog.js +24 -0
  7. package/dist/business/index.d.ts +3 -0
  8. package/dist/business/index.d.ts.map +1 -1
  9. package/dist/business/index.js +3 -0
  10. package/dist/business/print-send-dialog.d.ts +27 -0
  11. package/dist/business/print-send-dialog.d.ts.map +1 -0
  12. package/dist/business/print-send-dialog.js +55 -0
  13. package/dist/business/relate-documents.d.ts +41 -0
  14. package/dist/business/relate-documents.d.ts.map +1 -0
  15. package/dist/business/relate-documents.js +42 -0
  16. package/dist/business/vehicle-picker.d.ts +36 -0
  17. package/dist/business/vehicle-picker.d.ts.map +1 -0
  18. package/dist/business/vehicle-picker.js +61 -0
  19. package/dist/cfdi-stamp-panel.d.ts +29 -0
  20. package/dist/cfdi-stamp-panel.d.ts.map +1 -0
  21. package/dist/cfdi-stamp-panel.js +53 -0
  22. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  23. package/dist/dialogs/dynamic-record.js +5 -0
  24. package/dist/document-form-dialog.d.ts +23 -0
  25. package/dist/document-form-dialog.d.ts.map +1 -0
  26. package/dist/document-form-dialog.js +196 -0
  27. package/dist/dynamic-crud-page.d.ts +6 -0
  28. package/dist/dynamic-crud-page.d.ts.map +1 -1
  29. package/dist/dynamic-crud-page.js +17 -7
  30. package/dist/dynamic-kanban.d.ts.map +1 -1
  31. package/dist/dynamic-kanban.js +3 -1
  32. package/dist/dynamic-table.d.ts.map +1 -1
  33. package/dist/dynamic-table.js +6 -2
  34. package/dist/index.d.ts +6 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +6 -0
  37. package/dist/list-primary-action.d.ts +31 -0
  38. package/dist/list-primary-action.d.ts.map +1 -0
  39. package/dist/list-primary-action.js +35 -0
  40. package/dist/model-action-toolbar.d.ts +7 -1
  41. package/dist/model-action-toolbar.d.ts.map +1 -1
  42. package/dist/model-action-toolbar.js +10 -3
  43. package/dist/model-extension-registry.d.ts +7 -0
  44. package/dist/model-extension-registry.d.ts.map +1 -1
  45. package/dist/nav-permissions.d.ts +19 -0
  46. package/dist/nav-permissions.d.ts.map +1 -0
  47. package/dist/nav-permissions.js +51 -0
  48. package/dist/navigation-builder.d.ts +6 -0
  49. package/dist/navigation-builder.d.ts.map +1 -1
  50. package/dist/navigation-builder.js +26 -1
  51. package/dist/permissions-manager.d.ts +9 -0
  52. package/dist/permissions-manager.d.ts.map +1 -1
  53. package/dist/permissions-manager.js +50 -17
  54. package/dist/print-document-button.d.ts.map +1 -1
  55. package/dist/print-document-button.js +9 -1
  56. package/dist/record-mutation-events.d.ts +14 -0
  57. package/dist/record-mutation-events.d.ts.map +1 -0
  58. package/dist/record-mutation-events.js +37 -0
  59. package/dist/types.d.ts +60 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/use-print-document.d.ts +15 -1
  62. package/dist/use-print-document.d.ts.map +1 -1
  63. package/dist/use-print-document.js +52 -1
  64. package/dist/use-record-mutation-tick.d.ts +6 -0
  65. package/dist/use-record-mutation-tick.d.ts.map +1 -0
  66. package/dist/use-record-mutation-tick.js +11 -0
  67. package/package.json +3 -3
  68. package/src/__tests__/action-modal-feedback.test.tsx +116 -0
  69. package/src/__tests__/business-pickers.test.tsx +157 -0
  70. package/src/__tests__/document-form-dialog.test.tsx +190 -0
  71. package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
  72. package/src/__tests__/list-primary-action.test.tsx +157 -0
  73. package/src/__tests__/nav-permissions.test.tsx +76 -0
  74. package/src/__tests__/permissions-manager.test.tsx +21 -4
  75. package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
  76. package/src/action-modal-dispatcher.tsx +124 -6
  77. package/src/business/catalog.ts +24 -0
  78. package/src/business/index.ts +3 -0
  79. package/src/business/print-send-dialog.tsx +175 -0
  80. package/src/business/relate-documents.tsx +182 -0
  81. package/src/business/vehicle-picker.tsx +188 -0
  82. package/src/cfdi-stamp-panel.tsx +146 -0
  83. package/src/dialogs/dynamic-record.tsx +5 -0
  84. package/src/document-form-dialog.tsx +357 -0
  85. package/src/dynamic-crud-page.tsx +36 -7
  86. package/src/dynamic-kanban.tsx +3 -1
  87. package/src/dynamic-table.tsx +6 -2
  88. package/src/index.ts +36 -0
  89. package/src/list-primary-action.ts +71 -0
  90. package/src/model-action-toolbar.tsx +18 -3
  91. package/src/model-extension-registry.tsx +7 -0
  92. package/src/nav-permissions.ts +58 -0
  93. package/src/navigation-builder.tsx +27 -1
  94. package/src/permissions-manager.tsx +39 -26
  95. package/src/print-document-button.tsx +9 -1
  96. package/src/record-mutation-events.ts +50 -0
  97. package/src/types.ts +63 -0
  98. package/src/use-print-document.ts +66 -3
  99. package/src/use-record-mutation-tick.ts +12 -0
@@ -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)
@@ -0,0 +1,357 @@
1
+ // DocumentFormDialog — alta guiada por tipo de documento (FAC-12, #1022).
2
+ //
3
+ // "Crear documento fiscal" era el mismo formulario genérico para factura, nota
4
+ // de crédito, REP, global y traslado: los campos los dictaba el modelo, no el
5
+ // tipo. Aquí el metadata (`document_forms`) o una prop declara los tipos; el
6
+ // diálogo muestra tarjetas de tipo y, al elegir uno, SUS campos y (si lo declara)
7
+ // el paso de renglones con el editor de renglones del SDK. Un solo tipo se salta
8
+ // el selector; sin manifest el alta genérica sigue igual (DynamicCRUDPage decide).
9
+ //
10
+ // El SDK no hardcodea catálogos SAT: campos, valores fijos y el valor del tipo
11
+ // vienen del manifest que sirve el kernel o el addon.
12
+ import { useEffect, useMemo, useState } from 'react'
13
+ import { useTranslation } from 'react-i18next'
14
+ import {
15
+ Button,
16
+ Dialog,
17
+ DialogContent,
18
+ DialogDescription,
19
+ DialogFooter,
20
+ DialogHeader,
21
+ DialogTitle,
22
+ } from '@asteby/metacore-ui/primitives'
23
+ import { Loader2 } from 'lucide-react'
24
+ import { toast } from 'sonner'
25
+ import { useApi } from './api-context'
26
+ import { DynamicIcon } from './dynamic-icon'
27
+ import { FieldCell, FieldGrid, FieldLabel } from './field-grid'
28
+ import { FormErrorBanner } from './business/feedback'
29
+ import {
30
+ DocumentLinesGrid,
31
+ type DocumentLinesGridProps,
32
+ type LineItemsColumn,
33
+ } from './business/line-items-editor'
34
+ import { serializeLineItems, validateLineItems, type LineItem } from './business/line-items'
35
+ import {
36
+ actionErrorBannerMessage,
37
+ buildFieldDefaults,
38
+ renderField,
39
+ } from './action-modal-dispatcher'
40
+ import { extractFieldErrors, localizeFieldErrorMap } from './server-error'
41
+ import { validateValues, bagHasErrors } from './validator'
42
+ import { clearFieldErrorTree } from './field-validation-ui'
43
+ import { emitRecordMutation } from './record-mutation-events'
44
+ import { isLineItemsField, resolveWidget } from './dynamic-form-schema'
45
+ import type {
46
+ ActionFieldDef,
47
+ DocumentFormLines,
48
+ DocumentFormsManifest,
49
+ DocumentFormType,
50
+ TableMetadata,
51
+ } from './types'
52
+
53
+ export interface DocumentFormDialogProps {
54
+ open: boolean
55
+ onOpenChange: (open: boolean) => void
56
+ /** Modelo en el que se da de alta (p. ej. `fiscal_documents`). */
57
+ model: string
58
+ /** Endpoint de alta. Default `/dynamic/<model>`. */
59
+ endpoint?: string
60
+ forms: DocumentFormsManifest
61
+ /** Tipo preseleccionado (se salta el selector). */
62
+ initialType?: string
63
+ /** Se llama con el registro creado. */
64
+ onSaved?: (record?: unknown) => void
65
+ /** Buscador de productos para el editor de renglones (mismo contrato que ProductPicker). */
66
+ searchProducts?: DocumentLinesGridProps['search']
67
+ /** Moneda ISO para el editor de renglones (default: la de la org). */
68
+ currency?: string
69
+ }
70
+
71
+ /** Manifest efectivo: la prop gana sobre el metadata; sin tipos devuelve undefined. */
72
+ export function resolveDocumentForms(
73
+ metadata?: Pick<TableMetadata, 'document_forms'> | null,
74
+ override?: DocumentFormsManifest | null,
75
+ ): DocumentFormsManifest | undefined {
76
+ const m = override ?? metadata?.document_forms
77
+ return m && Array.isArray(m.types) && m.types.length > 0 ? m : undefined
78
+ }
79
+
80
+ function linesConfig(type: DocumentFormType, forms: DocumentFormsManifest): (DocumentFormLines & { field: string }) | undefined {
81
+ if (!type.lines) return undefined
82
+ const cfg: DocumentFormLines = type.lines === true ? {} : type.lines
83
+ return { ...cfg, field: cfg.field ?? forms.lines_field ?? 'lines' }
84
+ }
85
+
86
+ type Step = 'type' | 'fields' | 'lines'
87
+
88
+ export function DocumentFormDialog({
89
+ open,
90
+ onOpenChange,
91
+ model,
92
+ endpoint,
93
+ forms,
94
+ initialType,
95
+ onSaved,
96
+ searchProducts,
97
+ currency,
98
+ }: DocumentFormDialogProps) {
99
+ const { t, i18n } = useTranslation()
100
+ const api = useApi()
101
+ const types = forms.types
102
+ const single = types.length === 1 ? types[0] : undefined
103
+
104
+ const [typeKey, setTypeKey] = useState<string | null>(initialType ?? single?.key ?? null)
105
+ const [step, setStep] = useState<Step>(initialType || single ? 'fields' : 'type')
106
+ const [formData, setFormData] = useState<Record<string, any>>({})
107
+ const [lines, setLines] = useState<LineItem[]>([])
108
+ const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
109
+ const [serverLineErrors, setServerLineErrors] = useState<Record<string, string>>({})
110
+ const [formError, setFormError] = useState<string | undefined>()
111
+ const [saving, setSaving] = useState(false)
112
+
113
+ const type = useMemo(() => types.find((x) => x.key === typeKey) ?? null, [types, typeKey])
114
+ const lineCfg = type ? linesConfig(type, forms) : undefined
115
+
116
+ // Reset every time the dialog (re)opens.
117
+ useEffect(() => {
118
+ if (!open) return
119
+ const start = initialType ?? single?.key ?? null
120
+ setTypeKey(start)
121
+ setStep(start ? 'fields' : 'type')
122
+ setLines([])
123
+ setFieldErrors({})
124
+ setServerLineErrors({})
125
+ setFormError(undefined)
126
+ }, [open, initialType, single?.key])
127
+
128
+ // Seed field defaults for the chosen type.
129
+ useEffect(() => {
130
+ if (!type) return
131
+ setFormData({ ...buildFieldDefaults(type.fields, undefined), ...(type.defaults ?? {}) })
132
+ setFieldErrors({})
133
+ setFormError(undefined)
134
+ }, [type])
135
+
136
+ const updateField = (key: string, value: any) => {
137
+ setFormData((prev) => ({ ...prev, [key]: value }))
138
+ setFieldErrors((prev) => clearFieldErrorTree(prev, key))
139
+ }
140
+
141
+ const tl = (s: string) => t(s, { defaultValue: s })
142
+ const lang = i18n?.language
143
+
144
+ const chooseType = (k: string) => {
145
+ setTypeKey(k)
146
+ setStep('fields')
147
+ }
148
+
149
+ /** Validate the header fields; returns true when they pass. */
150
+ const validateHeader = (): boolean => {
151
+ if (!type) return false
152
+ const bag = validateValues(type.fields, formData)
153
+ if (!bagHasErrors(bag)) {
154
+ setFieldErrors({})
155
+ return true
156
+ }
157
+ const labels: Record<string, string> = {}
158
+ for (const f of type.fields) labels[f.key] = tl(f.label)
159
+ setFieldErrors(localizeFieldErrorMap(bag, t, { labels, language: lang }))
160
+ return false
161
+ }
162
+
163
+ const goNext = () => {
164
+ if (validateHeader()) setStep('lines')
165
+ }
166
+
167
+ const submit = async () => {
168
+ if (!type) return
169
+ if (!validateHeader()) {
170
+ setStep('fields')
171
+ return
172
+ }
173
+ if (lineCfg && (lineCfg.required ?? true)) {
174
+ const v = validateLineItems(lines, { requireItems: true })
175
+ if (!v.form && Object.keys(v.errors).length === 0) {
176
+ /* ok */
177
+ } else {
178
+ setServerLineErrors(v.errors)
179
+ setFormError(v.form ?? t('documentForm.lines_invalid', { defaultValue: 'Revisa los renglones del documento.' }))
180
+ setStep('lines')
181
+ return
182
+ }
183
+ }
184
+ setSaving(true)
185
+ setFormError(undefined)
186
+ try {
187
+ const payload: Record<string, unknown> = { ...(type.defaults ?? {}), ...formData }
188
+ if (forms.type_field) payload[forms.type_field] = type.value ?? type.key
189
+ if (lineCfg) payload[lineCfg.field] = serializeLineItems(lines)
190
+ const url = type.endpoint ?? endpoint ?? `/dynamic/${model}`
191
+ const res = await api.post(url, payload)
192
+ if (res.data?.success === false) {
193
+ handleError({ response: { data: res.data } })
194
+ return
195
+ }
196
+ toast.success(t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }))
197
+ emitRecordMutation(model, 'create')
198
+ onSaved?.(res.data?.data ?? res.data ?? undefined)
199
+ onOpenChange(false)
200
+ } catch (err) {
201
+ handleError(err)
202
+ } finally {
203
+ setSaving(false)
204
+ }
205
+ }
206
+
207
+ /** 422 → marks the fields and shows the banner INSIDE the dialog; stays open. */
208
+ const handleError = (err: unknown) => {
209
+ setFormError(actionErrorBannerMessage(err, type?.fields, t, lang))
210
+ const map = extractFieldErrors(err)
211
+ if (map) {
212
+ const labels: Record<string, string> = {}
213
+ for (const f of type?.fields ?? []) labels[f.key] = tl(f.label)
214
+ const localized = localizeFieldErrorMap(map, t, { labels, language: lang })
215
+ const header: Record<string, string> = {}
216
+ const lineErrs: Record<string, string> = {}
217
+ const prefix = lineCfg ? `${lineCfg.field}.` : null
218
+ for (const [k, v] of Object.entries(localized)) {
219
+ if (prefix && k.startsWith(prefix)) lineErrs[k.slice(prefix.length)] = v
220
+ else header[k] = v
221
+ }
222
+ setFieldErrors(header)
223
+ setServerLineErrors(lineErrs)
224
+ }
225
+ }
226
+
227
+ const canGoBackToTypes = types.length > 1 && !initialType
228
+ const stepLabel = (s: Step) =>
229
+ s === 'type'
230
+ ? t('documentForm.step_type', { defaultValue: 'Tipo de documento' })
231
+ : s === 'fields'
232
+ ? t('documentForm.step_data', { defaultValue: 'Datos' })
233
+ : tl(lineCfg?.title ?? 'Renglones')
234
+
235
+ return (
236
+ <Dialog open={open} onOpenChange={onOpenChange}>
237
+ <DialogContent
238
+ className="flex max-h-[90dvh] flex-col overflow-hidden sm:max-w-2xl"
239
+ style={{ maxHeight: '90dvh', ...(step === 'lines' ? { maxWidth: '980px', width: '95vw' } : {}) }}
240
+ data-slot="document-form-dialog"
241
+ >
242
+ <DialogHeader className="shrink-0">
243
+ <DialogTitle>
244
+ {type ? tl(type.label) : t('documentForm.title', { defaultValue: 'Nuevo documento' })}
245
+ </DialogTitle>
246
+ <DialogDescription>{stepLabel(step)}</DialogDescription>
247
+ </DialogHeader>
248
+
249
+ <div className="-mx-1 min-h-0 flex-1 space-y-3 overflow-y-auto px-1 py-3">
250
+ <FormErrorBanner message={formError} />
251
+
252
+ {step === 'type' && (
253
+ <div role="radiogroup" className="grid gap-3 sm:grid-cols-2">
254
+ {types.map((x) => (
255
+ <button
256
+ key={x.key}
257
+ type="button"
258
+ role="radio"
259
+ aria-checked={x.key === typeKey}
260
+ data-type-card={x.key}
261
+ onClick={() => chooseType(x.key)}
262
+ className="flex items-start gap-3 rounded-lg border p-4 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
263
+ >
264
+ {x.icon && <DynamicIcon name={x.icon} className="mt-0.5 size-5 shrink-0 text-primary" />}
265
+ <span>
266
+ <span className="block text-sm font-medium">{tl(x.label)}</span>
267
+ {x.description && (
268
+ <span className="block text-xs text-muted-foreground">{tl(x.description)}</span>
269
+ )}
270
+ </span>
271
+ </button>
272
+ ))}
273
+ </div>
274
+ )}
275
+
276
+ {step === 'fields' && type && (
277
+ <FieldGrid>
278
+ {type.fields.map((field: ActionFieldDef) => {
279
+ const fullWidth =
280
+ isLineItemsField(field) ||
281
+ resolveWidget(field) === 'textarea' ||
282
+ resolveWidget(field) === 'richtext'
283
+ return (
284
+ <FieldCell key={field.key} fullWidth={fullWidth}>
285
+ <FieldLabel htmlFor={field.key} required={field.required}>
286
+ {tl(field.label)}
287
+ </FieldLabel>
288
+ {renderField(
289
+ field,
290
+ formData[field.key],
291
+ (v: any) => updateField(field.key, v),
292
+ formData,
293
+ undefined,
294
+ fieldErrors,
295
+ )}
296
+ {fieldErrors[field.key] && (
297
+ <p className="mt-1 text-xs text-destructive">{fieldErrors[field.key]}</p>
298
+ )}
299
+ </FieldCell>
300
+ )
301
+ })}
302
+ </FieldGrid>
303
+ )}
304
+
305
+ {step === 'lines' && type && lineCfg && (
306
+ <DocumentLinesGrid
307
+ value={lines}
308
+ onChange={(next) => {
309
+ setLines(next)
310
+ setServerLineErrors({})
311
+ }}
312
+ columns={(lineCfg.columns as LineItemsColumn[] | undefined) ?? ['discount', 'tax']}
313
+ priceSource={lineCfg.price_source ?? 'sale'}
314
+ serverErrors={serverLineErrors}
315
+ search={searchProducts}
316
+ currency={currency}
317
+ />
318
+ )}
319
+ </div>
320
+
321
+ <DialogFooter className="shrink-0">
322
+ {step === 'type' ? (
323
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
324
+ {t('common.cancel', { defaultValue: 'Cancelar' })}
325
+ </Button>
326
+ ) : (
327
+ <Button
328
+ type="button"
329
+ variant="outline"
330
+ disabled={saving}
331
+ onClick={() => {
332
+ if (step === 'lines') setStep('fields')
333
+ else if (canGoBackToTypes) setStep('type')
334
+ else onOpenChange(false)
335
+ }}
336
+ >
337
+ {step === 'fields' && !canGoBackToTypes
338
+ ? t('common.cancel', { defaultValue: 'Cancelar' })
339
+ : t('common.back', { defaultValue: 'Atrás' })}
340
+ </Button>
341
+ )}
342
+ {step === 'fields' && type && lineCfg && (
343
+ <Button type="button" onClick={goNext}>
344
+ {t('common.next', { defaultValue: 'Siguiente' })}
345
+ </Button>
346
+ )}
347
+ {((step === 'fields' && type && !lineCfg) || step === 'lines') && (
348
+ <Button type="button" onClick={submit} disabled={saving}>
349
+ {saving && <Loader2 className="mr-2 size-4 animate-spin" aria-hidden />}
350
+ {type?.submit_label ? tl(type.submit_label) : t('common.create', { defaultValue: 'Crear' })}
351
+ </Button>
352
+ )}
353
+ </DialogFooter>
354
+ </DialogContent>
355
+ </Dialog>
356
+ )
357
+ }
@@ -33,12 +33,14 @@ import { useApi } from './api-context'
33
33
  import { useMetadataCache } from './metadata-cache'
34
34
  import { DynamicTable } from './dynamic-table'
35
35
  import { DynamicRecordDialog } from './dialogs/dynamic-record'
36
+ import { DocumentFormDialog, resolveDocumentForms } from './document-form-dialog'
36
37
  import { ExportDialog } from './dialogs/export'
37
38
  import { ImportDialog } from './dialogs/import'
38
39
  import { getModelExtension } from './model-extension-registry'
39
40
  import { ModelActionToolbar } from './model-action-toolbar'
41
+ import { resolveListPrimaryAction } from './list-primary-action'
40
42
  import { useCan, modelCapability } from './permissions-context'
41
- import type { TableMetadata } from './types'
43
+ import type { DocumentFormsManifest, TableMetadata } from './types'
42
44
 
43
45
  export interface DynamicCRUDPageStrings {
44
46
  refresh?: string
@@ -89,6 +91,11 @@ export interface DynamicCRUDPageProps {
89
91
  classes?: DynamicCRUDPageClasses
90
92
  /** Fired after a create/import/refresh successfully reloads the table. */
91
93
  onChange?: () => void
94
+ /**
95
+ * Guided create by document type (FAC-12). Wins over `metadata.document_forms`;
96
+ * with neither, the generic record dialog is used as before.
97
+ */
98
+ documentForms?: DocumentFormsManifest
92
99
  }
93
100
 
94
101
  /**
@@ -111,6 +118,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
111
118
  toolbarExtras,
112
119
  classes,
113
120
  onChange,
121
+ documentForms,
114
122
  } = props
115
123
 
116
124
  const strings = { ...defaultStrings, ...(i18n ?? {}) }
@@ -159,10 +167,17 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
159
167
  }, [title])
160
168
 
161
169
  const enableCRUD = metadata?.enableCRUDActions ?? false
162
- // A "create"-placement action ships a custom create experience — it
163
- // replaces the generic create button (the ModelActionToolbar renders it).
164
- const hasCreateAction = metadata?.actions?.some((a) => a.placement === 'create') ?? false
165
- const effectiveHideCreate = hideCreate || ext?.hideCreate || hasCreateAction
170
+ // Una sola primaria: placement create, replaces_create, create_mode o la
171
+ // extensión del modelo ocultan el Crear genérico. El shell de ops debe
172
+ // usar el mismo resolveListPrimaryAction.
173
+ const primary = resolveListPrimaryAction({
174
+ enableCRUD,
175
+ canCreate: metadata?.canCreate,
176
+ hideCreate: hideCreate || ext?.hideCreate,
177
+ createMode: ext?.createMode ?? metadata?.create_mode,
178
+ primaryActionKey: ext?.primaryActionKey,
179
+ actions: metadata?.actions,
180
+ })
166
181
  const effectiveHideExport = hideExport || ext?.hideExport
167
182
  const effectiveHideImport = hideImport || ext?.hideImport
168
183
  // Refresh defaults to hidden in the page header — <DynamicTable> ships
@@ -174,10 +189,11 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
174
189
  // (useCan defaults to always-true), in which case create/export/import
175
190
  // require `lowercase(model).create|export|import`.
176
191
  const can = useCan()
177
- const showCreate = enableCRUD && !effectiveHideCreate && can(modelCapability(model, 'create'))
192
+ const showCreate = primary.showGenericCreate && can(modelCapability(model, 'create'))
178
193
  const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import'))
179
194
  const showExport = !effectiveHideExport && can(modelCapability(model, 'export'))
180
195
  const showRefresh = !effectiveHideRefresh
196
+ const guidedForms = resolveDocumentForms(metadata, documentForms)
181
197
 
182
198
  const handleRefresh = useCallback(() => {
183
199
  setRefreshKey((k) => k + 1)
@@ -239,11 +255,13 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
239
255
  model={model}
240
256
  endpoint={dataEndpoint}
241
257
  actions={metadata?.actions}
258
+ primaryActionKey={ext?.primaryActionKey}
242
259
  onChange={handleRefresh}
243
260
  />
244
261
  {showCreate && (
245
262
  <button
246
263
  type='button'
264
+ data-primary='true'
247
265
  onClick={() => setOpenCreate(true)}
248
266
  className='inline-flex items-center gap-2 h-9 px-3 rounded-md bg-primary text-primary-foreground hover:opacity-90 text-sm font-medium'
249
267
  >
@@ -264,7 +282,18 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
264
282
  </div>
265
283
  </div>
266
284
 
267
- {showCreate && (
285
+ {showCreate && guidedForms && (
286
+ <DocumentFormDialog
287
+ open={openCreate}
288
+ onOpenChange={setOpenCreate}
289
+ model={model}
290
+ endpoint={dataEndpoint}
291
+ forms={guidedForms}
292
+ onSaved={handleRefresh}
293
+ />
294
+ )}
295
+
296
+ {showCreate && !guidedForms && (
268
297
  <DynamicRecordDialog
269
298
  open={openCreate}
270
299
  onOpenChange={setOpenCreate}
@@ -152,6 +152,7 @@ import { useFlipAnimation } from './use-flip-animation'
152
152
  import { PortalDragOverlay } from './portal-drag-overlay'
153
153
  import { objectLabel } from './dynamic-relation-helpers'
154
154
  import { useMetadataCache } from './metadata-cache'
155
+ import { useRecordMutationTick } from './use-record-mutation-tick'
155
156
  import { useOptionsResolver } from './use-options-resolver'
156
157
  import { ActivityValueRenderer } from './activity-value-renderer'
157
158
  import { DynamicIcon } from './dynamic-icon'
@@ -923,6 +924,7 @@ function DynamicKanbanBoard({
923
924
  // Realtime refetch (opt-in) — debounced counter bumped by DATA_EVENTs for
924
925
  // this model; folded into the board refetch effect next to refreshTrigger.
925
926
  const realtimeDefault = useRealtimeDefault()
927
+ const mutationTick = useRecordMutationTick(model)
926
928
  const realtimeTick = useRealtimeTick({
927
929
  models: [model],
928
930
  enabled: realtimeProp ?? realtimeDefault,
@@ -1339,7 +1341,7 @@ function DynamicKanbanBoard({
1339
1341
  return () => clearTimeout(handle)
1340
1342
  // realtimeTick: data events for this model (see the `realtime` prop).
1341
1343
  // eslint-disable-next-line react-hooks/exhaustive-deps
1342
- }, [fetchData, metadata, refreshTrigger, realtimeTick])
1344
+ }, [fetchData, metadata, refreshTrigger, realtimeTick, mutationTick])
1343
1345
 
1344
1346
  // Filterable fields for the toolbar, in metadata order (explicit filters
1345
1347
  // first, then filterable columns), each labeled from its metadata source.