@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,115 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // `option_filter` / `optionFilter` on a relation picker hides options client-side
4
+ // (PIT-059: cancelled invoices must not show up in the payments selector).
5
+ import { afterEach, describe, expect, it, vi } from 'vitest'
6
+ import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react'
7
+ import { applyOptionFilter, getOptionFilter, optionPassesRule } from '../option-filter'
8
+ import { projectOption } from '../use-options-resolver'
9
+ import { DynamicSelectField } from '../dynamic-select-field'
10
+ import { ApiProvider, type ApiClient } from '../api-context'
11
+ import type { ActionFieldDef } from '../types'
12
+
13
+ vi.mock('react-i18next', () => ({
14
+ useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
15
+ }))
16
+
17
+ afterEach(cleanup)
18
+
19
+ const invoices = [
20
+ { id: 'i1', label: 'FAC-1', status: 'timbrada', total: 100 },
21
+ { id: 'i2', label: 'FAC-2', status: 'Cancelada', total: 50 },
22
+ { id: 'i3', label: 'FAC-3', status: 'pendiente', total: 70 },
23
+ { id: 'i4', label: 'FAC-4' },
24
+ ].map(projectOption)
25
+
26
+ describe('getOptionFilter', () => {
27
+ it('is empty when the field declares none (retrocompat)', () => {
28
+ expect(getOptionFilter({ key: 'x' })).toEqual([])
29
+ expect(getOptionFilter(undefined)).toEqual([])
30
+ })
31
+ it('reads camelCase and snake_case, one rule or a list', () => {
32
+ const rule = { field: 'status', not_in: ['cancelada'] }
33
+ expect(getOptionFilter({ optionFilter: rule })).toEqual([rule])
34
+ expect(getOptionFilter({ option_filter: [rule] })).toEqual([rule])
35
+ })
36
+ it('ignores malformed rules', () => {
37
+ expect(getOptionFilter({ option_filter: [{ nope: 1 }, null, { field: '' }] })).toEqual([])
38
+ })
39
+ })
40
+
41
+ describe('applyOptionFilter', () => {
42
+ it('returns the same array with no rules', () => {
43
+ expect(applyOptionFilter(invoices, [])).toBe(invoices)
44
+ })
45
+ it('not_in hides cancelled invoices (case-insensitive) and keeps options without the column', () => {
46
+ const out = applyOptionFilter(invoices, [{ field: 'status', not_in: ['cancelada'] }])
47
+ expect(out.map((o) => o.id)).toEqual(['i1', 'i3', 'i4'])
48
+ })
49
+ it('notEquals alias and equals / in need the column to exist', () => {
50
+ expect(applyOptionFilter(invoices, [{ field: 'status', notEquals: 'cancelada' }]).map((o) => o.id)).toEqual(['i1', 'i3', 'i4'])
51
+ expect(applyOptionFilter(invoices, [{ field: 'status', equals: 'timbrada' }]).map((o) => o.id)).toEqual(['i1'])
52
+ expect(applyOptionFilter(invoices, [{ field: 'status', in: ['timbrada', 'pendiente'] }]).map((o) => o.id)).toEqual(['i1', 'i3'])
53
+ })
54
+ it('combines rules with AND', () => {
55
+ const out = applyOptionFilter(invoices, [
56
+ { field: 'status', not_in: ['cancelada'] },
57
+ { field: 'status', not_equals: 'pendiente' },
58
+ ])
59
+ expect(out.map((o) => o.id)).toEqual(['i1', 'i4'])
60
+ })
61
+ it('never hides the current selection', () => {
62
+ const out = applyOptionFilter(invoices, [{ field: 'status', not_in: ['cancelada'] }], 'i2')
63
+ expect(out.map((o) => o.id)).toEqual(['i1', 'i2', 'i3', 'i4'])
64
+ })
65
+ it('tests standard keys too (label)', () => {
66
+ expect(optionPassesRule(invoices[0], { field: 'label', equals: 'fac-1' })).toBe(true)
67
+ })
68
+ })
69
+
70
+ describe('DynamicSelectField with option_filter', () => {
71
+ const field = (extra: Partial<ActionFieldDef> = {}): ActionFieldDef => ({
72
+ key: 'invoice_id',
73
+ label: 'Factura',
74
+ type: 'dynamic_select',
75
+ ref: 'invoices.Invoice',
76
+ ...extra,
77
+ })
78
+ const api = () => {
79
+ const get = vi.fn(async () => ({
80
+ data: {
81
+ success: true,
82
+ data: [
83
+ { id: 'i1', label: 'FAC-1', status: 'timbrada' },
84
+ { id: 'i2', label: 'FAC-2', status: 'cancelada' },
85
+ ],
86
+ meta: { type: 'dynamic', count: 2 },
87
+ },
88
+ }))
89
+ const post = vi.fn(async () => ({ data: { success: false } }))
90
+ return { get, post } as unknown as ApiClient
91
+ }
92
+ const open = async (f: ActionFieldDef) => {
93
+ render(
94
+ <ApiProvider client={api()}>
95
+ <DynamicSelectField field={f} value="" onChange={() => {}} />
96
+ </ApiProvider>,
97
+ )
98
+ await act(async () => {
99
+ screen.getByRole('combobox').click()
100
+ })
101
+ }
102
+
103
+ it('lists every option without a filter', async () => {
104
+ await open(field())
105
+ await waitFor(() => expect(screen.getByText('FAC-2')).toBeTruthy())
106
+ expect(screen.getByText('FAC-1')).toBeTruthy()
107
+ })
108
+
109
+ it('hides the cancelled invoice when the manifest declares option_filter', async () => {
110
+ await open(field({ option_filter: { field: 'status', not_in: ['cancelada'] } }))
111
+ await waitFor(() => expect(screen.getByText('FAC-1')).toBeTruthy())
112
+ expect(screen.queryByText('FAC-2')).toBeNull()
113
+ expect(within(document.body).queryAllByText('FAC-2')).toHaveLength(0)
114
+ })
115
+ })
@@ -249,9 +249,25 @@ describe('PermissionsManager (módulo como combobox agrupado)', () => {
249
249
  expect(screen.getByRole('option', { name: /Pedidos POS/ })).toBeTruthy()
250
250
  expect(screen.getByRole('option', { name: /Terminal/ })).toBeTruthy()
251
251
 
252
- // Generales presentes con descripción.
253
- expect(screen.getByText('Permisos Generales')).toBeTruthy()
254
- expect(screen.getByText('Trabajar fuera de horario')).toBeTruthy()
252
+ // Los generales son un grupo sintético del combobox (#1021), no cards
253
+ // sueltas junto a la tarjeta del rol.
254
+ expect(screen.getByRole('option', { name: /Permisos generales/ })).toBeTruthy()
255
+ expect(screen.queryByText('Trabajar fuera de horario')).toBeNull()
256
+ })
257
+
258
+ it('«Permisos generales» se edita en la misma grilla de dos columnas y cuenta en el total', async () => {
259
+ const props = makeProps()
260
+ render(<PermissionsManager {...props} />)
261
+ await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
262
+
263
+ await selectModule(/Permisos generales/)
264
+ expect(await screen.findByText('Trabajar fuera de horario')).toBeTruthy()
265
+ expect(screen.getByText('0/1')).toBeTruthy()
266
+ const box = screen.getByRole('checkbox', { name: /Trabajar fuera de horario/ })
267
+ // Misma grilla que las acciones de un módulo.
268
+ expect(box.closest('div.grid')?.className).toContain('sm:grid-cols-2')
269
+ fireEvent.click(box)
270
+ expect(screen.getByText('1/1')).toBeTruthy()
255
271
  })
256
272
 
257
273
  it('CERO acordeones: el módulo es un combobox, no un folder colapsable', async () => {
@@ -304,7 +320,8 @@ describe('PermissionsManager (módulo como combobox agrupado)', () => {
304
320
  await selectModule(/Pedidos POS/)
305
321
  await screen.findByText('Pagar')
306
322
  fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
307
- fireEvent.click(screen.getByRole('checkbox', { name: /Trabajar fuera de horario/ }))
323
+ await selectModule(/Permisos generales/)
324
+ fireEvent.click(await screen.findByRole('checkbox', { name: /Trabajar fuera de horario/ }))
308
325
 
309
326
  expect(screen.getByText('Cambios sin guardar')).toBeTruthy()
310
327
  fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
@@ -82,7 +82,11 @@ describe('detail-view jsonb line-items (ViewValue → inline CollectionCell)', (
82
82
  record={{}}
83
83
  />
84
84
  )
85
- expect(screen.getByText('Precio:')).toBeTruthy()
85
+ // Keys are humanized (the addon-declared label is the localized source);
86
+ // each pair renders as a <dt>/<dd>, never "[object Object]".
87
+ expect(screen.getByText('Price').tagName).toBe('DT')
88
+ expect(screen.getByText('10').tagName).toBe('DD')
89
+ expect(screen.getByText('Quantity')).toBeTruthy()
86
90
  expect(container.textContent).not.toContain('[object Object]')
87
91
  })
88
92
 
@@ -0,0 +1,58 @@
1
+ // @vitest-environment happy-dom
2
+ // «Descargar Factura CFDI» no daba señal alguna: ahora hay progreso, éxito y
3
+ // el motivo del servidor si falla (la promesa sigue rechazando).
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import { cleanup, renderHook } from '@testing-library/react'
6
+ import type { ReactNode } from 'react'
7
+
8
+ const toast = vi.hoisted(() => ({
9
+ loading: vi.fn(() => 'tid'),
10
+ success: vi.fn(),
11
+ error: vi.fn(),
12
+ dismiss: vi.fn(),
13
+ }))
14
+ vi.mock('sonner', () => ({ toast }))
15
+
16
+ import { usePrintDocument, documentErrorMessage } from '../use-print-document'
17
+ import { ApiProvider, type ApiClient } from '../api-context'
18
+
19
+ afterEach(() => {
20
+ cleanup()
21
+ vi.clearAllMocks()
22
+ })
23
+
24
+ const wrap = (client: ApiClient) => ({ children }: { children: ReactNode }) => <ApiProvider client={client}>{children}</ApiProvider>
25
+
26
+ describe('usePrintDocument feedback', () => {
27
+ it('descarga: toast de progreso y de éxito', async () => {
28
+ URL.createObjectURL = vi.fn(() => 'blob:x')
29
+ const client = { get: vi.fn().mockResolvedValue({ data: new Blob(['x']), headers: {} }) } as unknown as ApiClient
30
+ const { result } = renderHook(() => usePrintDocument(), { wrapper: wrap(client) })
31
+ await result.current({ model: 'Invoice', id: '1', key: 'cfdi', mode: 'download' })
32
+ expect(toast.loading).toHaveBeenCalled()
33
+ expect(toast.success).toHaveBeenCalledWith('Documento descargado')
34
+ expect(toast.dismiss).toHaveBeenCalled()
35
+ expect(toast.error).not.toHaveBeenCalled()
36
+ })
37
+
38
+ it('fallo: muestra el motivo del servidor (cuerpo Blob) y rechaza', async () => {
39
+ const body = new Blob([JSON.stringify({ message: 'La factura aún no está timbrada' })], { type: 'application/json' })
40
+ const client = { get: vi.fn().mockRejectedValue({ response: { status: 409, data: body } }) } as unknown as ApiClient
41
+ const { result } = renderHook(() => usePrintDocument(), { wrapper: wrap(client) })
42
+ await expect(result.current({ model: 'Invoice', id: '1', key: 'cfdi', mode: 'download' })).rejects.toBeTruthy()
43
+ expect(toast.error).toHaveBeenCalledWith('La factura aún no está timbrada')
44
+ })
45
+
46
+ it('feedback:false no muestra nada; error:false deja el error al llamador', async () => {
47
+ const client = { get: vi.fn().mockRejectedValue({ response: { status: 500, data: null } }) } as unknown as ApiClient
48
+ const { result } = renderHook(() => usePrintDocument(), { wrapper: wrap(client) })
49
+ await expect(result.current({ model: 'I', id: '1', key: 'k', mode: 'open', feedback: false })).rejects.toBeTruthy()
50
+ await expect(result.current({ model: 'I', id: '1', key: 'k', mode: 'open', feedback: { progress: true, error: false } })).rejects.toBeTruthy()
51
+ expect(toast.error).not.toHaveBeenCalled()
52
+ })
53
+
54
+ it('documentErrorMessage cae a un mensaje genérico', async () => {
55
+ expect(await documentErrorMessage(new Error('x'))).toMatch(/No se pudo generar/)
56
+ expect(await documentErrorMessage({ response: { status: 404 } })).toMatch(/no está disponible/)
57
+ })
58
+ })
@@ -14,7 +14,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
14
14
  import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'
15
15
 
16
16
  vi.mock('react-i18next', () => ({
17
- useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k }),
17
+ useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
18
18
  }))
19
19
 
20
20
  import { ActionModalDispatcher } from '../action-modal-dispatcher'
@@ -14,7 +14,8 @@
14
14
  //
15
15
  // The host injects its axios-like client via <ApiProvider>; we no longer
16
16
  // depend on a bundler alias to `@/lib/api`.
17
- import { useState, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
17
+ import { emitRecordMutation } from './record-mutation-events'
18
+ import { useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
18
19
  import { useTranslation } from 'react-i18next'
19
20
  import {
20
21
  Dialog,
@@ -44,7 +45,9 @@ import {
44
45
  import { Loader2 } from 'lucide-react'
45
46
  import { ProcessStepper } from '@asteby/metacore-ui/wizard'
46
47
  import { toast } from 'sonner'
47
- import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldErrorMap } from './server-error'
48
+ import { toastServerError, toastServerSuccess, extractFieldErrors, extractServerError, localizeFieldErrorMap } from './server-error'
49
+ import { FormErrorBanner } from './business/feedback'
50
+ import { CfdiStampResultDialog, extractStampResult, type CfdiStampResult } from './cfdi-stamp-panel'
48
51
  import { useBranchCreateGate } from './branch-create-gate'
49
52
  import type { Translate } from './server-error'
50
53
  import { validateValues, bagHasErrors } from './validator'
@@ -358,8 +361,15 @@ export function ActionModalDispatcher({
358
361
  model,
359
362
  record,
360
363
  endpoint,
361
- onSuccess,
364
+ onSuccess: onSuccessProp,
362
365
  }: ActionModalProps) {
366
+ // Every successful action may have created/updated/deleted a record of
367
+ // `model` (federated component, wizard, generic form or confirmation), so
368
+ // announce it: mounted lists of the model reload on their own (#1020).
369
+ const onSuccess = useCallback(() => {
370
+ emitRecordMutation(model, 'update')
371
+ onSuccessProp?.()
372
+ }, [model, onSuccessProp])
363
373
  // Reactive read: a federated remote usually registers AFTER the first
364
374
  // render, and a memoized lookup would pin the fallback forever.
365
375
  const readComponent = () => getActionComponent(model, action.key)
@@ -794,10 +804,34 @@ function toastActionError(
794
804
  toastServerError(err, { t, language })
795
805
  }
796
806
 
807
+ /** Text for the in-dialog error banner of a failed action (PIT-046, #1023): a
808
+ * 422 with per-field `errors` -> "Revisa los campos" + each localized line; any
809
+ * other failure (500, network) -> the server's own message and cause. Unlike
810
+ * the toast it stays on screen until the next attempt. */
811
+ export function actionErrorBannerMessage(
812
+ err: unknown,
813
+ fields: readonly ActionFieldDef[] | undefined,
814
+ t: Translate,
815
+ language?: string,
816
+ ): string {
817
+ const localized = localizeActionFieldErrors(err, fields, t, language)
818
+ if (localized) {
819
+ const head = t('validation.failed', { defaultValue: validationCatalog(language).failed })
820
+ const lines = Object.values(localized).filter(Boolean)
821
+ return lines.length ? `${head}: ${lines.join(' · ')}` : head
822
+ }
823
+ const fallback = t('common.error', { defaultValue: 'Algo salió mal. Intenta de nuevo.' })
824
+ const ex = extractServerError(err, fallback)
825
+ const title = t(ex.title, { defaultValue: ex.title })
826
+ return ex.description && ex.description !== title ? `${title} — ${ex.description}` : title
827
+ }
828
+
797
829
  function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
798
830
  const { t, i18n } = useTranslation()
799
831
  const api = useApi()
800
832
  const [executing, setExecuting] = useState(false)
833
+ const [formError, setFormError] = useState<string | undefined>()
834
+ const [stamp, setStamp] = useState<CfdiStampResult | undefined>()
801
835
  // Actions listed in the model's `reason_required.actions` (cancel, void…)
802
836
  // answer 422 errors.reason without a motive; the prompt asks and retries.
803
837
  const reasonPrompt = useReasonPrompt()
@@ -809,6 +843,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
809
843
 
810
844
  const execute = async () => {
811
845
  setExecuting(true)
846
+ setFormError(undefined)
812
847
  try {
813
848
  const url = buildActionUrl(endpoint, model, record.id, action.key)
814
849
  // Supervisor PIN first (action.supervisorPolicy); null = cancelled.
@@ -823,18 +858,41 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
823
858
  if (!res) return
824
859
  if (res.data.success) {
825
860
  toastServerSuccess(res.data, { t })
861
+ const result = extractStampResult(res.data)
862
+ if (result) {
863
+ setStamp(result)
864
+ return
865
+ }
826
866
  onOpenChange(false)
827
867
  onSuccess()
828
868
  } else {
869
+ setFormError(actionErrorBannerMessage({ response: { data: res.data } }, action.fields, t, i18n.language))
829
870
  toastActionError({ response: { data: res.data } }, action.fields, t, i18n.language)
830
871
  }
831
872
  } catch (err: any) {
873
+ setFormError(actionErrorBannerMessage(err, action.fields, t, i18n.language))
832
874
  toastActionError(err, action.fields, t, i18n.language)
833
875
  } finally {
834
876
  setExecuting(false)
835
877
  }
836
878
  }
837
879
 
880
+ if (stamp) {
881
+ return (
882
+ <CfdiStampResultDialog
883
+ open={open}
884
+ result={stamp}
885
+ title={label}
886
+ onOpenChange={(o) => {
887
+ if (o) return
888
+ setStamp(undefined)
889
+ onOpenChange(false)
890
+ onSuccess()
891
+ }}
892
+ />
893
+ )
894
+ }
895
+
838
896
  return (
839
897
  <>
840
898
  {reasonPrompt.dialog}
@@ -850,6 +908,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
850
908
  </AlertDialogDescription>
851
909
  </AlertDialogHeader>
852
910
  {record ? <RecordPreview model={model} record={record} /> : null}
911
+ <FormErrorBanner message={formError} />
853
912
  <AlertDialogFooter>
854
913
  <AlertDialogCancel disabled={executing}>{t('common.cancel')}</AlertDialogCancel>
855
914
  <AlertDialogAction
@@ -881,6 +940,10 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
881
940
  const [executing, setExecuting] = useState(false)
882
941
  // Per-field validation errors (localized), shown inline under each input.
883
942
  const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
943
+ // Persistent in-dialog banner for a failed submit (422 / 500) — the toast
944
+ // alone vanished and, for some failures, only the console had the cause.
945
+ const [formError, setFormError] = useState<string | undefined>()
946
+ const [stamp, setStamp] = useState<CfdiStampResult | undefined>()
884
947
  // Related records to surface BELOW the form, as read-only context for the
885
948
  // record being acted on — e.g. the reception history of a transfer while
886
949
  // receiving against it. Sourced from the model's metadata.relations (the
@@ -912,6 +975,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
912
975
  if (open && action.fields) {
913
976
  setFormData(buildFieldDefaults(action.fields, record))
914
977
  setFieldErrors({})
978
+ setFormError(undefined)
915
979
  }
916
980
  }, [open, action.fields, record])
917
981
 
@@ -999,6 +1063,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
999
1063
 
1000
1064
  const lang = i18n.language
1001
1065
  const handleActionError = (err: unknown) => {
1066
+ setFormError(actionErrorBannerMessage(err, action.fields, t, lang))
1002
1067
  const labels = labelsForValidationFields(action.fields, t)
1003
1068
  const localized = localizeActionFieldErrors(err, action.fields, t, lang)
1004
1069
  if (localized) {
@@ -1035,6 +1100,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1035
1100
  }
1036
1101
  }
1037
1102
  setFieldErrors({})
1103
+ setFormError(undefined)
1038
1104
  setExecuting(true)
1039
1105
  try {
1040
1106
  let payload: Record<string, any> = { ...formData }
@@ -1069,6 +1135,11 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1069
1135
  if (!res) return
1070
1136
  if (res.data.success) {
1071
1137
  toastServerSuccess(res.data, { t })
1138
+ const result = extractStampResult(res.data)
1139
+ if (result) {
1140
+ setStamp(result)
1141
+ return
1142
+ }
1072
1143
  onOpenChange(false)
1073
1144
  onSuccess()
1074
1145
  } else {
@@ -1102,6 +1173,22 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1102
1173
  ? '820px'
1103
1174
  : undefined
1104
1175
 
1176
+ if (stamp) {
1177
+ return (
1178
+ <CfdiStampResultDialog
1179
+ open={open}
1180
+ result={stamp}
1181
+ title={tl(action.label)}
1182
+ onOpenChange={(o) => {
1183
+ if (o) return
1184
+ setStamp(undefined)
1185
+ onOpenChange(false)
1186
+ onSuccess()
1187
+ }}
1188
+ />
1189
+ )
1190
+ }
1191
+
1105
1192
  return (
1106
1193
  <Dialog open={open} onOpenChange={onOpenChange}>
1107
1194
  {/* Sticky header + footer, scrollable body: the form can grow tall
@@ -1127,6 +1214,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1127
1214
  (in FieldCell) keeps a long select/input value from blowing the
1128
1215
  grid past the dialog and spawning a horizontal scrollbar. */}
1129
1216
  <div className="-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-4">
1217
+ <FormErrorBanner message={formError} className="mb-3" />
1130
1218
  <FieldGrid>
1131
1219
  {action.fields?.map((field) => {
1132
1220
  const fullWidth =
@@ -1183,7 +1271,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1183
1271
  // buildFieldDefaults seeds formData for a set of action fields, honoring the
1184
1272
  // same line-items prefill spec + boolean/empty rules GenericActionModal uses, so
1185
1273
  // wizard steps and single-page forms initialize identically.
1186
- function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<string, any> {
1274
+ export function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<string, any> {
1187
1275
  const defaults: Record<string, any> = {}
1188
1276
  for (const field of fields) {
1189
1277
  if (isLineItemsField(field)) {
@@ -1235,6 +1323,8 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1235
1323
  const [stepIndex, setStepIndex] = useState(0)
1236
1324
  const [formData, setFormData] = useState<Record<string, any>>({})
1237
1325
  const [executing, setExecuting] = useState(false)
1326
+ const [formError, setFormError] = useState<string | undefined>()
1327
+ const [stamp, setStamp] = useState<CfdiStampResult | undefined>()
1238
1328
 
1239
1329
  // Reset to the first step and seed defaults for EVERY step's fields whenever
1240
1330
  // the modal (re)opens, so accumulated values from a prior run don't leak.
@@ -1243,6 +1333,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1243
1333
  const allFields = steps.flatMap((s) => s.fields ?? [])
1244
1334
  setFormData(buildFieldDefaults(allFields, record))
1245
1335
  setStepIndex(0)
1336
+ setFormError(undefined)
1246
1337
  }, [open, action.steps, record])
1247
1338
 
1248
1339
  const updateField = (key: string, value: any) =>
@@ -1284,6 +1375,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1284
1375
  }
1285
1376
  }
1286
1377
  setExecuting(true)
1378
+ setFormError(undefined)
1287
1379
  try {
1288
1380
  const url = buildActionUrl(endpoint, model, record?.id, action.key)
1289
1381
  const auth = await supervisor.authorize(
@@ -1297,14 +1389,22 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1297
1389
  request: (reason) => api.post(url, withApproval(reason ? { ...formData, reason } : formData, auth)),
1298
1390
  })
1299
1391
  if (!res) return
1392
+ const allFields = steps.flatMap(s => s.fields ?? [])
1300
1393
  if (res.data.success) {
1301
1394
  toastServerSuccess(res.data, { t })
1395
+ const result = extractStampResult(res.data)
1396
+ if (result) {
1397
+ setStamp(result)
1398
+ return
1399
+ }
1302
1400
  onOpenChange(false)
1303
1401
  onSuccess()
1304
1402
  } else {
1305
- toastActionError({ response: { data: res.data } }, steps.flatMap(s => s.fields ?? []), t, i18n.language)
1403
+ setFormError(actionErrorBannerMessage({ response: { data: res.data } }, allFields, t, i18n.language))
1404
+ toastActionError({ response: { data: res.data } }, allFields, t, i18n.language)
1306
1405
  }
1307
1406
  } catch (err: any) {
1407
+ setFormError(actionErrorBannerMessage(err, steps.flatMap(s => s.fields ?? []), t, i18n.language))
1308
1408
  toastActionError(err, steps.flatMap(s => s.fields ?? []), t, i18n.language)
1309
1409
  } finally {
1310
1410
  setExecuting(false)
@@ -1313,6 +1413,22 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1313
1413
 
1314
1414
  if (steps.length === 0) return null
1315
1415
 
1416
+ if (stamp) {
1417
+ return (
1418
+ <CfdiStampResultDialog
1419
+ open={open}
1420
+ result={stamp}
1421
+ title={tl(action.label)}
1422
+ onOpenChange={(o) => {
1423
+ if (o) return
1424
+ setStamp(undefined)
1425
+ onOpenChange(false)
1426
+ onSuccess()
1427
+ }}
1428
+ />
1429
+ )
1430
+ }
1431
+
1316
1432
  return (
1317
1433
  <Dialog open={open} onOpenChange={onOpenChange}>
1318
1434
  <DialogContent
@@ -1347,6 +1463,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1347
1463
  persist in the one formData object, so navigating back and
1348
1464
  forth keeps entries intact. */}
1349
1465
  <div className="-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-4">
1466
+ <FormErrorBanner message={formError} className="mb-3" />
1350
1467
  <FieldGrid>
1351
1468
  {stepFields.map((field) => {
1352
1469
  const fullWidth =
@@ -1423,7 +1540,8 @@ function seedOptionFromRecord(
1423
1540
  }
1424
1541
  }
1425
1542
 
1426
- function renderField(
1543
+ /** Renders one action field (same widgets as the action modals). Shared with DocumentFormDialog. */
1544
+ export function renderField(
1427
1545
  field: ActionFieldDef,
1428
1546
  value: any,
1429
1547
  onChange: (value: any) => void,