@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +25 -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 +4 -2
  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 +5 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -0
  37. package/dist/nav-permissions.d.ts +19 -0
  38. package/dist/nav-permissions.d.ts.map +1 -0
  39. package/dist/nav-permissions.js +51 -0
  40. package/dist/navigation-builder.d.ts +6 -0
  41. package/dist/navigation-builder.d.ts.map +1 -1
  42. package/dist/navigation-builder.js +26 -1
  43. package/dist/permissions-manager.d.ts +9 -0
  44. package/dist/permissions-manager.d.ts.map +1 -1
  45. package/dist/permissions-manager.js +50 -17
  46. package/dist/print-document-button.d.ts.map +1 -1
  47. package/dist/print-document-button.js +9 -1
  48. package/dist/record-mutation-events.d.ts +14 -0
  49. package/dist/record-mutation-events.d.ts.map +1 -0
  50. package/dist/record-mutation-events.js +37 -0
  51. package/dist/types.d.ts +46 -0
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/use-print-document.d.ts +15 -1
  54. package/dist/use-print-document.d.ts.map +1 -1
  55. package/dist/use-print-document.js +52 -1
  56. package/dist/use-record-mutation-tick.d.ts +6 -0
  57. package/dist/use-record-mutation-tick.d.ts.map +1 -0
  58. package/dist/use-record-mutation-tick.js +11 -0
  59. package/package.json +3 -3
  60. package/src/__tests__/action-modal-feedback.test.tsx +116 -0
  61. package/src/__tests__/business-pickers.test.tsx +157 -0
  62. package/src/__tests__/document-form-dialog.test.tsx +190 -0
  63. package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
  64. package/src/__tests__/nav-permissions.test.tsx +76 -0
  65. package/src/__tests__/permissions-manager.test.tsx +21 -4
  66. package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
  67. package/src/action-modal-dispatcher.tsx +124 -6
  68. package/src/business/catalog.ts +24 -0
  69. package/src/business/index.ts +3 -0
  70. package/src/business/print-send-dialog.tsx +175 -0
  71. package/src/business/relate-documents.tsx +182 -0
  72. package/src/business/vehicle-picker.tsx +188 -0
  73. package/src/cfdi-stamp-panel.tsx +146 -0
  74. package/src/dialogs/dynamic-record.tsx +5 -0
  75. package/src/document-form-dialog.tsx +357 -0
  76. package/src/dynamic-crud-page.tsx +21 -2
  77. package/src/dynamic-kanban.tsx +3 -1
  78. package/src/dynamic-table.tsx +6 -2
  79. package/src/index.ts +28 -0
  80. package/src/nav-permissions.ts +58 -0
  81. package/src/navigation-builder.tsx +27 -1
  82. package/src/permissions-manager.tsx +39 -26
  83. package/src/print-document-button.tsx +9 -1
  84. package/src/record-mutation-events.ts +50 -0
  85. package/src/types.ts +49 -0
  86. package/src/use-print-document.ts +66 -3
  87. package/src/use-record-mutation-tick.ts +12 -0
@@ -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,13 +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'
40
41
  import { resolveListPrimaryAction } from './list-primary-action'
41
42
  import { useCan, modelCapability } from './permissions-context'
42
- import type { TableMetadata } from './types'
43
+ import type { DocumentFormsManifest, TableMetadata } from './types'
43
44
 
44
45
  export interface DynamicCRUDPageStrings {
45
46
  refresh?: string
@@ -90,6 +91,11 @@ export interface DynamicCRUDPageProps {
90
91
  classes?: DynamicCRUDPageClasses
91
92
  /** Fired after a create/import/refresh successfully reloads the table. */
92
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
93
99
  }
94
100
 
95
101
  /**
@@ -112,6 +118,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
112
118
  toolbarExtras,
113
119
  classes,
114
120
  onChange,
121
+ documentForms,
115
122
  } = props
116
123
 
117
124
  const strings = { ...defaultStrings, ...(i18n ?? {}) }
@@ -186,6 +193,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
186
193
  const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import'))
187
194
  const showExport = !effectiveHideExport && can(modelCapability(model, 'export'))
188
195
  const showRefresh = !effectiveHideRefresh
196
+ const guidedForms = resolveDocumentForms(metadata, documentForms)
189
197
 
190
198
  const handleRefresh = useCallback(() => {
191
199
  setRefreshKey((k) => k + 1)
@@ -274,7 +282,18 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
274
282
  </div>
275
283
  </div>
276
284
 
277
- {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 && (
278
297
  <DynamicRecordDialog
279
298
  open={openCreate}
280
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.
@@ -66,6 +66,7 @@ import { Inbox, Download, Upload, Trash2 } from 'lucide-react'
66
66
  import { toast } from 'sonner'
67
67
  import { Progress } from './dialogs/_primitives'
68
68
  import { useMetadataCache } from './metadata-cache'
69
+ import { useRecordMutationTick } from './use-record-mutation-tick'
69
70
  import { useApi, useCurrentBranch } from './api-context'
70
71
  import { useReasonPrompt } from './reason-prompt'
71
72
  import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
@@ -295,6 +296,9 @@ export function DynamicTable({
295
296
  // OrgRuntimeProvider (without it, money fell back to USD).
296
297
  const orgTimeZone = useTimeZone()
297
298
  const orgCurrency = useCurrency()
299
+ // Bumps when a dialog/modal anywhere creates, edits or deletes a record of
300
+ // this model, so the list reloads without the caller wiring refreshTrigger.
301
+ const mutationTick = useRecordMutationTick(model)
298
302
  const timeZone = timeZoneProp ?? orgTimeZone
299
303
  const currency = currencyProp ?? orgCurrency
300
304
  // The explicit `pagination` prop wins; the legacy `infiniteScroll` boolean
@@ -917,7 +921,7 @@ export function DynamicTable({
917
921
  } finally {
918
922
  setLoadingData(false)
919
923
  }
920
- }, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync, relationInclude])
924
+ }, [model, metadata, pagination, buildFilterParams, refreshTrigger, mutationTick, endpoint, currentBranch?.id, api, enableUrlSync, relationInclude])
921
925
 
922
926
  // Columns whose metadata opts into a footer total (display_config.aggregate
923
927
  // → styleConfig.aggregate). When empty, no footer row is rendered and no
@@ -1098,7 +1102,7 @@ export function DynamicTable({
1098
1102
  // deps). Without it the comment above lied: infinite lists silently
1099
1103
  // failed to reload after a create ("a veces no recarga la tabla").
1100
1104
  // eslint-disable-next-line react-hooks/exhaustive-deps
1101
- }, [infiniteScroll, metadata, filterSignature, refreshTrigger])
1105
+ }, [infiniteScroll, metadata, filterSignature, refreshTrigger, mutationTick])
1102
1106
 
1103
1107
  const handleRefresh = useCallback(() => {
1104
1108
  // Infinite mode owns its own list: refresh reloads page 1 and drops the
package/src/index.ts CHANGED
@@ -196,6 +196,34 @@ export {
196
196
  type ListActionRef,
197
197
  type CreateMode,
198
198
  } from './list-primary-action'
199
+ export {
200
+ emitRecordMutation,
201
+ subscribeRecordMutations,
202
+ RECORD_MUTATION_EVENT,
203
+ type RecordMutationKind,
204
+ type RecordMutationDetail,
205
+ } from './record-mutation-events'
206
+ export {
207
+ DocumentFormDialog,
208
+ resolveDocumentForms,
209
+ type DocumentFormDialogProps,
210
+ } from './document-form-dialog'
211
+ export {
212
+ modelFromNavUrl,
213
+ capabilityForNavItem,
214
+ isNavItemAllowed,
215
+ useNavItemVisible,
216
+ type NavLeafLike,
217
+ } from './nav-permissions'
218
+ export {
219
+ CfdiStampPanel,
220
+ CfdiStampResultDialog,
221
+ extractStampResult,
222
+ type CfdiStampResult,
223
+ type CfdiStampPanelProps,
224
+ type CfdiStampResultDialogProps,
225
+ } from './cfdi-stamp-panel'
226
+ export { useRecordMutationTick } from './use-record-mutation-tick'
199
227
  export * from './addon-loader'
200
228
  export {
201
229
  PURGE_ADDON_MESSAGE,
@@ -0,0 +1,58 @@
1
+ // nav-permissions — the sidebar follows the role's EFFECTIVE permissions
2
+ // (PIT-044). Before, every installed addon's entries were listed for every
3
+ // role and only failed with a 403 after the click.
4
+ //
5
+ // Rules (same capability vocabulary as useCan/gateTableMetadata):
6
+ // - no <PermissionsProvider> mounted, or isAdmin / "*" -> nothing hidden
7
+ // (useCan() is always-true then, so the predicate is a no-op);
8
+ // - an entry with an explicit `requires` capability -> can(requires);
9
+ // - an entry pointing at a model screen (`/m/<model>`) -> can('<model>.index');
10
+ // - any other entry (custom pages, activity, settings…) -> kept. Those are
11
+ // gated by the backend / the host's own `requires`, never guessed here.
12
+ import { useCallback } from 'react'
13
+ import { modelCapability, useCan, usePermissionsActive, type CanFn } from './permissions-context'
14
+
15
+ /** Minimal structural shape of a sidebar leaf (matches `@asteby/metacore-ui` NavLinkItem). */
16
+ export interface NavLeafLike {
17
+ url: string
18
+ requires?: string
19
+ }
20
+
21
+ const MODEL_URL_RE = /^\/(?:app\/)?m\/([^/?#]+)/i
22
+
23
+ /** Model key of a `/m/<model>` url, or undefined for any other route. */
24
+ export function modelFromNavUrl(url: string | undefined): string | undefined {
25
+ if (!url) return undefined
26
+ const m = MODEL_URL_RE.exec(url)
27
+ if (!m) return undefined
28
+ try {
29
+ return decodeURIComponent(m[1])
30
+ } catch {
31
+ return m[1]
32
+ }
33
+ }
34
+
35
+ /** Capability a leaf needs, or undefined when it is not gated by the SDK. */
36
+ export function capabilityForNavItem(item: NavLeafLike): string | undefined {
37
+ if (item.requires) return item.requires
38
+ const model = modelFromNavUrl(item.url)
39
+ return model ? modelCapability(model, 'index') : undefined
40
+ }
41
+
42
+ /** Pure visibility predicate for a leaf given a capability checker. */
43
+ export function isNavItemAllowed(item: NavLeafLike, can: CanFn): boolean {
44
+ const cap = capabilityForNavItem(item)
45
+ return cap ? can(cap) : true
46
+ }
47
+
48
+ /**
49
+ * Hook for the host's sidebar: returns a predicate for `<AppSidebar
50
+ * isItemVisible>` / `filterNavGroups`, or `undefined` when permission gating
51
+ * is not active (nothing to filter).
52
+ */
53
+ export function useNavItemVisible(): ((item: NavLeafLike) => boolean) | undefined {
54
+ const can = useCan()
55
+ const active = usePermissionsActive()
56
+ const predicate = useCallback((item: NavLeafLike) => isNavItemAllowed(item, can), [can])
57
+ return active ? predicate : undefined
58
+ }
@@ -1,6 +1,7 @@
1
1
  // NavigationBuilder — merges a host's base sidebar with `manifest.navigation`
2
2
  // contributions from every loaded addon. Pure function + a React hook.
3
3
  import { useMemo } from 'react'
4
+ import { useCan, type CanFn } from './permissions-context'
4
5
 
5
6
  export interface NavItem {
6
7
  key: string
@@ -59,8 +60,33 @@ export function mergeNavigation(base: NavItem[], contributions: AddonNavigationC
59
60
  .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
60
61
  }
61
62
 
63
+ /**
64
+ * Drops items whose `requires` capability the user lacks (PIT-044). A parent
65
+ * left without children is dropped too, unless it links somewhere itself.
66
+ */
67
+ export function filterNavigationByCapability(items: NavItem[], can: CanFn): NavItem[] {
68
+ const out: NavItem[] = []
69
+ for (const item of items) {
70
+ if (item.requires && !can(item.requires)) continue
71
+ if (item.children) {
72
+ const children = filterNavigationByCapability(item.children, can)
73
+ if (children.length === 0 && item.children.length > 0 && !item.to) continue
74
+ out.push({ ...item, children })
75
+ } else {
76
+ out.push(item)
77
+ }
78
+ }
79
+ return out
80
+ }
81
+
62
82
  export function useNavigation(base: NavItem[], contributions: AddonNavigationContribution[]): NavItem[] {
63
- return useMemo(() => mergeNavigation(base, contributions), [base, contributions])
83
+ // useCan() allows everything without a <PermissionsProvider>, so hosts that
84
+ // never opt in keep the full tree.
85
+ const can = useCan()
86
+ return useMemo(
87
+ () => filterNavigationByCapability(mergeNavigation(base, contributions), can),
88
+ [base, contributions, can],
89
+ )
64
90
  }
65
91
 
66
92
  export interface NavigationBuilderProps {