@asteby/metacore-runtime-react 47.1.0 → 48.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 (145) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  3. package/dist/action-modal-dispatcher.js +24 -4
  4. package/dist/business/catalog-product-search.d.ts +25 -0
  5. package/dist/business/catalog-product-search.d.ts.map +1 -0
  6. package/dist/business/catalog-product-search.js +57 -0
  7. package/dist/business/catalog.d.ts.map +1 -1
  8. package/dist/business/catalog.js +2 -0
  9. package/dist/business/document-editor-model.d.ts +107 -0
  10. package/dist/business/document-editor-model.d.ts.map +1 -0
  11. package/dist/business/document-editor-model.js +279 -0
  12. package/dist/business/document-editor-panels.d.ts +64 -0
  13. package/dist/business/document-editor-panels.d.ts.map +1 -0
  14. package/dist/business/document-editor-panels.js +58 -0
  15. package/dist/business/document-editor.d.ts +27 -0
  16. package/dist/business/document-editor.d.ts.map +1 -0
  17. package/dist/business/document-editor.js +445 -0
  18. package/dist/business/document-lines.d.ts.map +1 -1
  19. package/dist/business/document-lines.js +5 -1
  20. package/dist/business/index.d.ts +5 -0
  21. package/dist/business/index.d.ts.map +1 -1
  22. package/dist/business/index.js +5 -0
  23. package/dist/business/line-items.d.ts +7 -0
  24. package/dist/business/line-items.d.ts.map +1 -1
  25. package/dist/business/line-items.js +6 -1
  26. package/dist/business/payment-allocator.d.ts +19 -0
  27. package/dist/business/payment-allocator.d.ts.map +1 -0
  28. package/dist/business/payment-allocator.js +31 -0
  29. package/dist/business/product-search.d.ts +4 -0
  30. package/dist/business/product-search.d.ts.map +1 -1
  31. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  32. package/dist/dialogs/dynamic-record.js +14 -7
  33. package/dist/document-form-dialog.d.ts +31 -2
  34. package/dist/document-form-dialog.d.ts.map +1 -1
  35. package/dist/document-form-dialog.js +43 -11
  36. package/dist/dynamic-columns-helpers.d.ts +9 -0
  37. package/dist/dynamic-columns-helpers.d.ts.map +1 -1
  38. package/dist/dynamic-columns-helpers.js +19 -0
  39. package/dist/dynamic-columns.d.ts +8 -2
  40. package/dist/dynamic-columns.d.ts.map +1 -1
  41. package/dist/dynamic-columns.js +21 -5
  42. package/dist/dynamic-crud-page.d.ts +6 -0
  43. package/dist/dynamic-crud-page.d.ts.map +1 -1
  44. package/dist/dynamic-crud-page.js +14 -5
  45. package/dist/dynamic-form-schema.d.ts.map +1 -1
  46. package/dist/dynamic-form-schema.js +15 -3
  47. package/dist/dynamic-form.d.ts +16 -1
  48. package/dist/dynamic-form.d.ts.map +1 -1
  49. package/dist/dynamic-form.js +78 -20
  50. package/dist/dynamic-kanban.d.ts +9 -3
  51. package/dist/dynamic-kanban.d.ts.map +1 -1
  52. package/dist/dynamic-kanban.js +31 -12
  53. package/dist/dynamic-relation-helpers.d.ts.map +1 -1
  54. package/dist/dynamic-relation-helpers.js +8 -0
  55. package/dist/dynamic-relation.js +1 -1
  56. package/dist/index.d.ts +5 -2
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +13 -2
  59. package/dist/installed-addons-context.d.ts +15 -0
  60. package/dist/installed-addons-context.d.ts.map +1 -0
  61. package/dist/installed-addons-context.js +39 -0
  62. package/dist/org-runtime-context.d.ts +8 -0
  63. package/dist/org-runtime-context.d.ts.map +1 -1
  64. package/dist/org-runtime-context.js +8 -0
  65. package/dist/org-runtime-provider.d.ts +3 -1
  66. package/dist/org-runtime-provider.d.ts.map +1 -1
  67. package/dist/org-runtime-provider.js +3 -3
  68. package/dist/primitives/aging.d.ts +34 -0
  69. package/dist/primitives/aging.d.ts.map +1 -0
  70. package/dist/primitives/aging.js +48 -0
  71. package/dist/primitives/allocation.d.ts +42 -0
  72. package/dist/primitives/allocation.d.ts.map +1 -0
  73. package/dist/primitives/allocation.js +59 -0
  74. package/dist/primitives/approvals.d.ts +71 -0
  75. package/dist/primitives/approvals.d.ts.map +1 -0
  76. package/dist/primitives/approvals.js +48 -0
  77. package/dist/primitives/contributions.d.ts +82 -0
  78. package/dist/primitives/contributions.d.ts.map +1 -0
  79. package/dist/primitives/contributions.js +126 -0
  80. package/dist/primitives/document-kinds.d.ts +25 -0
  81. package/dist/primitives/document-kinds.d.ts.map +1 -0
  82. package/dist/primitives/document-kinds.js +26 -0
  83. package/dist/primitives/index.d.ts +8 -0
  84. package/dist/primitives/index.d.ts.map +1 -0
  85. package/dist/primitives/index.js +23 -0
  86. package/dist/primitives/panels.d.ts +68 -0
  87. package/dist/primitives/panels.d.ts.map +1 -0
  88. package/dist/primitives/panels.js +3 -0
  89. package/dist/primitives/use-contributions.d.ts +15 -0
  90. package/dist/primitives/use-contributions.d.ts.map +1 -0
  91. package/dist/primitives/use-contributions.js +30 -0
  92. package/dist/record-prefill-bar.d.ts +11 -0
  93. package/dist/record-prefill-bar.d.ts.map +1 -0
  94. package/dist/record-prefill-bar.js +33 -0
  95. package/dist/types.d.ts +102 -0
  96. package/dist/types.d.ts.map +1 -1
  97. package/package.json +3 -3
  98. package/src/__tests__/catalog-product-search.test.ts +61 -0
  99. package/src/__tests__/document-editor-model.test.ts +167 -0
  100. package/src/__tests__/document-editor.test.tsx +261 -0
  101. package/src/__tests__/document-form-dialog.test.tsx +118 -3
  102. package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
  103. package/src/__tests__/dynamic-kanban.test.tsx +24 -3
  104. package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
  105. package/src/__tests__/installed-addons-context.test.tsx +50 -0
  106. package/src/__tests__/line-item-totals.test.ts +28 -0
  107. package/src/__tests__/primitives-federated.test.tsx +156 -0
  108. package/src/__tests__/primitives.test.ts +224 -0
  109. package/src/__tests__/record-prefill-bar.test.tsx +50 -0
  110. package/src/action-modal-dispatcher.tsx +47 -4
  111. package/src/business/catalog-product-search.ts +87 -0
  112. package/src/business/catalog.ts +2 -0
  113. package/src/business/document-editor-model.ts +325 -0
  114. package/src/business/document-editor-panels.tsx +164 -0
  115. package/src/business/document-editor.tsx +727 -0
  116. package/src/business/document-lines.ts +5 -1
  117. package/src/business/index.ts +5 -0
  118. package/src/business/line-items.ts +15 -1
  119. package/src/business/payment-allocator.tsx +110 -0
  120. package/src/business/product-search.ts +4 -0
  121. package/src/dialogs/dynamic-record.tsx +29 -6
  122. package/src/document-form-dialog.tsx +92 -12
  123. package/src/dynamic-columns-helpers.ts +22 -0
  124. package/src/dynamic-columns.tsx +23 -6
  125. package/src/dynamic-crud-page.tsx +22 -3
  126. package/src/dynamic-form-schema.ts +15 -2
  127. package/src/dynamic-form.tsx +117 -39
  128. package/src/dynamic-kanban.tsx +34 -7
  129. package/src/dynamic-relation-helpers.ts +9 -0
  130. package/src/dynamic-relation.tsx +7 -2
  131. package/src/index.ts +21 -1
  132. package/src/installed-addons-context.tsx +58 -0
  133. package/src/org-runtime-context.ts +10 -0
  134. package/src/org-runtime-provider.tsx +7 -2
  135. package/src/primitives/aging.ts +76 -0
  136. package/src/primitives/allocation.ts +104 -0
  137. package/src/primitives/approvals.ts +103 -0
  138. package/src/primitives/contributions.ts +208 -0
  139. package/src/primitives/document-kinds.ts +62 -0
  140. package/src/primitives/index.ts +23 -0
  141. package/src/primitives/panels.ts +59 -0
  142. package/src/primitives/use-contributions.ts +42 -0
  143. package/src/record-prefill-bar.tsx +62 -0
  144. package/src/row-action-stage-gate.test.ts +33 -0
  145. package/src/types.ts +101 -0
@@ -0,0 +1,156 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Primitivos federados en React: el dispatcher pinta un modal de
4
+ // registerFederatedModal sin esperar al host, usa el formulario genérico de
5
+ // inmediato si el addon del slug no está instalado, y useDocumentContributions
6
+ // re-pinta al registrar/desmontar un remote.
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { act, cleanup, render, screen } from '@testing-library/react'
9
+
10
+ const USE_TRANSLATION = {
11
+ t: (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k,
12
+ i18n: { language: 'es' },
13
+ }
14
+ vi.mock('react-i18next', () => ({ useTranslation: () => USE_TRANSLATION }))
15
+ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
16
+
17
+ import { ActionModalDispatcher } from '../action-modal-dispatcher'
18
+ import { setFederatedActionLoader } from '../federated-action-loader'
19
+ import { ApiProvider, type ApiClient } from '../api-context'
20
+ import { InstalledAddonsProvider } from '../installed-addons-context'
21
+ import {
22
+ __resetContributions,
23
+ registerDocumentContribution,
24
+ registerFederatedModal,
25
+ type DocumentEditorContext,
26
+ } from '../primitives/contributions'
27
+ import { useDocumentContributions } from '../primitives/use-contributions'
28
+ import type { ActionMetadata, ActionModalProps } from '@asteby/metacore-sdk'
29
+
30
+ afterEach(() => {
31
+ cleanup()
32
+ vi.restoreAllMocks()
33
+ setFederatedActionLoader(null)
34
+ __resetContributions()
35
+ })
36
+
37
+ const noopApi = {
38
+ get: vi.fn().mockResolvedValue({ data: {} }),
39
+ post: vi.fn().mockResolvedValue({ data: { success: true } }),
40
+ delete: vi.fn().mockResolvedValue({ data: { success: true } }),
41
+ } as unknown as ApiClient
42
+
43
+ const importAction: ActionMetadata = {
44
+ key: 'import_cfdi',
45
+ label: 'Importar CFDI',
46
+ modal: 'fiscal_mexico.import_cfdi',
47
+ confirm: true,
48
+ confirmMessage: '¿Importar?',
49
+ executable: true,
50
+ }
51
+
52
+ function ImportModal({ action }: ActionModalProps) {
53
+ return <div data-testid="import-modal">{action.modal}</div>
54
+ }
55
+
56
+ function renderDispatcher(addons?: string[]) {
57
+ const tree = (
58
+ <ActionModalDispatcher
59
+ open
60
+ onOpenChange={() => {}}
61
+ action={importAction}
62
+ model="Invoice"
63
+ record={{ id: '1' } as any}
64
+ endpoint="/data/Invoice"
65
+ onSuccess={() => {}}
66
+ />
67
+ )
68
+ return render(
69
+ <ApiProvider client={noopApi}>
70
+ {addons ? <InstalledAddonsProvider addons={addons}>{tree}</InstalledAddonsProvider> : tree}
71
+ </ApiProvider>,
72
+ )
73
+ }
74
+
75
+ describe('ActionModalDispatcher + registerFederatedModal', () => {
76
+ it('pinta el modal registrado (lazy) sin pedir el remote al host', async () => {
77
+ const loader = vi.fn()
78
+ setFederatedActionLoader(loader)
79
+ registerFederatedModal({ key: 'fiscal_mexico.import_cfdi', addon: 'fiscal_mexico', load: async () => ({ default: ImportModal }) })
80
+ renderDispatcher(['fiscal_mexico'])
81
+ expect((await screen.findByTestId('import-modal')).textContent).toBe('fiscal_mexico.import_cfdi')
82
+ expect(loader).not.toHaveBeenCalled()
83
+ expect(screen.queryByText('¿Importar?')).toBeNull()
84
+ })
85
+
86
+ it('addon del slug no instalado → formulario genérico de inmediato, sin esperar ni pedir el remote', () => {
87
+ const loader = vi.fn()
88
+ setFederatedActionLoader(loader)
89
+ registerFederatedModal({ key: 'fiscal_mexico.import_cfdi', addon: 'fiscal_mexico', load: async () => ({ default: ImportModal }) })
90
+ renderDispatcher(['customers'])
91
+ expect(screen.getByText('¿Importar?')).toBeTruthy()
92
+ expect(screen.queryByTestId('federated-action-loading')).toBeNull()
93
+ expect(loader).not.toHaveBeenCalled()
94
+ })
95
+
96
+ it('addon instalado pero modal aún sin registrar → espera al remote (no cae al genérico)', async () => {
97
+ renderDispatcher(['fiscal_mexico'])
98
+ expect(screen.getByTestId('federated-action-loading')).toBeTruthy()
99
+ expect(screen.queryByText('¿Importar?')).toBeNull()
100
+ act(() => {
101
+ registerFederatedModal({ key: 'fiscal_mexico.import_cfdi', addon: 'fiscal_mexico', load: async () => ({ default: ImportModal }) })
102
+ })
103
+ expect(await screen.findByTestId('import-modal')).toBeTruthy()
104
+ })
105
+
106
+ it('sin InstalledAddonsProvider se conserva la espera de siempre', () => {
107
+ renderDispatcher()
108
+ expect(screen.getByTestId('federated-action-loading')).toBeTruthy()
109
+ expect(screen.queryByText('¿Importar?')).toBeNull()
110
+ })
111
+ })
112
+
113
+ describe('useDocumentContributions', () => {
114
+ const ctx: DocumentEditorContext = { kind: 'invoice', model: 'customers.Invoice', values: {}, mode: 'create' }
115
+
116
+ function Region() {
117
+ const items = useDocumentContributions(ctx, 'header.fields')
118
+ return (
119
+ <div data-testid="region">
120
+ {items.map(({ id, component: C }) => (
121
+ <C key={id} ctx={ctx} setValue={() => {}} issues={{ report: () => {} }} />
122
+ ))}
123
+ </div>
124
+ )
125
+ }
126
+
127
+ it('re-pinta al registrar y al desmontar el remote; filtra por addon instalado', async () => {
128
+ render(
129
+ <InstalledAddonsProvider addons={['fiscal_mexico']}>
130
+ <Region />
131
+ </InstalledAddonsProvider>,
132
+ )
133
+ expect(screen.getByTestId('region').textContent).toBe('')
134
+ let off = () => {}
135
+ act(() => {
136
+ off = registerDocumentContribution({
137
+ id: 'fiscal_mexico.header.cfdi',
138
+ kinds: ['invoice'],
139
+ region: 'header.fields',
140
+ requiresAddon: 'fiscal_mexico',
141
+ load: async () => ({ default: () => <span>Uso del CFDI</span> }),
142
+ })
143
+ registerDocumentContribution({
144
+ id: 'inventory.header.warehouse',
145
+ kinds: '*',
146
+ region: 'header.fields',
147
+ requiresAddon: 'inventory',
148
+ component: () => <span>Almacén</span>,
149
+ })
150
+ })
151
+ expect(await screen.findByText('Uso del CFDI')).toBeTruthy()
152
+ expect(screen.queryByText('Almacén')).toBeNull()
153
+ act(() => off())
154
+ expect(screen.queryByText('Uso del CFDI')).toBeNull()
155
+ })
156
+ })
@@ -0,0 +1,224 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { allocatePayment, validateAllocation, type OpenDocument } from '../primitives/allocation'
3
+ import { bucketFor, computeAging, daysOverdue } from '../primitives/aging'
4
+ import {
5
+ __resetContributions,
6
+ contributionsVersion,
7
+ federatedModalComponent,
8
+ registerDocumentContribution,
9
+ registerFederatedModal,
10
+ resolveContributions,
11
+ resolveFederatedModal,
12
+ slotIdFor,
13
+ subscribeContributions,
14
+ } from '../primitives/contributions'
15
+ import { approvalsClient, groupApprovals, parseJSONField, registerApprovalCategory, type ApprovalRequestDTO } from '../primitives/approvals'
16
+ import { CORE_DOCUMENT_KINDS, getDocumentKind, listDocumentKinds, registerDocumentKind } from '../primitives/document-kinds'
17
+ import { blockingChecks } from '../primitives/panels'
18
+ import { installedPredicate } from '../primitives/use-contributions'
19
+ import { slotRegistry } from '../slot'
20
+ import type { ApiClient } from '../api-context'
21
+
22
+ // Datos del mockup complemento-pago.html (Transportes del Bajío).
23
+ const docs: OpenDocument[] = [
24
+ { id: 'a1060', number: 'A-1060', issued_at: '2026-09-10', due_at: '2026-10-10', total: 42270.4, balance: 42270.4, currency: 'MXN', payment_method: 'PPD' },
25
+ { id: 'a1051', number: 'A-1051', issued_at: '2026-08-15', due_at: '2026-09-14', total: 18560, balance: 8500, currency: 'MXN', payment_method: 'PPD', installments_paid: 1 },
26
+ { id: 'a1066', number: 'A-1066', issued_at: '2026-09-28', due_at: '2026-10-28', total: 23260, balance: 23260, currency: 'MXN', payment_method: 'PPD' },
27
+ ]
28
+
29
+ describe('allocatePayment', () => {
30
+ it('aplica primero lo más vencido y calcula parcialidad e insoluto', () => {
31
+ const r = allocatePayment(30000, docs)
32
+ expect(r.allocations).toEqual([
33
+ { document_id: 'a1051', amount: 8500, balance_before: 8500, balance_after: 0, installment: 2 },
34
+ { document_id: 'a1060', amount: 21500, balance_before: 42270.4, balance_after: 20770.4, installment: 1 },
35
+ ])
36
+ expect(r.unapplied).toBe(0)
37
+ })
38
+ it('manual respeta montos y deja sin aplicar el resto', () => {
39
+ const r = allocatePayment(10000, docs, 'manual', { a1066: 4000 })
40
+ expect(r.applied).toBe(4000)
41
+ expect(r.unapplied).toBe(6000)
42
+ expect(validateAllocation(10000, 'MXN', docs, r.allocations).map(i => i.code)).toEqual(['unapplied'])
43
+ expect(validateAllocation(10000, 'MXN', docs, r.allocations, { allowUnapplied: true })[0].severity).toBe('warning')
44
+ })
45
+ it('detecta pago mayor al saldo y moneda distinta', () => {
46
+ const codes = validateAllocation(9000, 'USD', docs, [{ document_id: 'a1051', amount: 9000, balance_before: 8500, balance_after: -500, installment: 2 }]).map(i => i.code)
47
+ expect(codes).toContain('over_balance')
48
+ expect(codes).toContain('currency_mismatch')
49
+ })
50
+ })
51
+
52
+ describe('computeAging', () => {
53
+ it('tramos y vencido por cliente al corte', () => {
54
+ expect(daysOverdue('2026-09-14', '2026-10-05')).toBe(21)
55
+ const { rows, totals, total } = computeAging(docs.map(d => ({ party_id: 'tba', due_at: d.due_at ?? null, issued_at: d.issued_at, balance: d.balance })), '2026-10-05')
56
+ expect(totals.current).toBe(65530.4)
57
+ expect(totals.d1_30).toBe(8500)
58
+ expect(total).toBe(74030.4)
59
+ expect(rows[0].oldest_overdue_days).toBe(21)
60
+ })
61
+ })
62
+
63
+ describe('contribuciones federadas', () => {
64
+ afterEach(() => __resetContributions())
65
+ const ctx = { kind: 'invoice', model: 'customers.Invoice', values: {}, mode: 'create' as const }
66
+ it('se ocultan si el addon no está instalado y se ordenan por prioridad', () => {
67
+ const Comp = () => null
68
+ registerDocumentContribution({ id: 'fiscal_mexico.header.cfdi', kinds: ['invoice', 'credit_note'], region: 'header.fields', requiresAddon: 'fiscal_mexico', priority: 10, component: Comp })
69
+ registerDocumentContribution({ id: 'inventory.header.warehouse', kinds: '*', region: 'header.fields', requiresAddon: 'inventory', component: Comp })
70
+ expect(resolveContributions(ctx, 'header.fields', a => a === 'inventory').map(c => c.id)).toEqual(['inventory.header.warehouse'])
71
+ expect(resolveContributions(ctx, 'header.fields', () => true).map(c => c.id)).toEqual(['fiscal_mexico.header.cfdi', 'inventory.header.warehouse'])
72
+ })
73
+ it('el disposer la quita (desinstalación en caliente)', () => {
74
+ const off = registerDocumentContribution({ id: 'x', kinds: '*', region: 'side.panels', component: () => null })
75
+ off()
76
+ expect(resolveContributions(ctx, 'side.panels', () => true)).toEqual([])
77
+ })
78
+ it('modal federado sin addon → null (el dispatcher usa el formulario genérico)', () => {
79
+ registerFederatedModal({ key: 'fiscal_mexico.import_cfdi', addon: 'fiscal_mexico', load: async () => ({ default: () => null }) })
80
+ expect(resolveFederatedModal('fiscal_mexico.import_cfdi', () => false)).toBeNull()
81
+ expect(resolveFederatedModal('fiscal_mexico.import_cfdi', () => true)?.addon).toBe('fiscal_mexico')
82
+ })
83
+ })
84
+
85
+ describe('groupApprovals', () => {
86
+ it('agrupa por categoría registrada por cada addon', () => {
87
+ const off = registerApprovalCategory({ key: 'cfdi', label: 'Cancelación CFDI', match: r => r.action_key === 'cancel_fiscal' })
88
+ const base = { kind: 'action', status: 'pending' } as ApprovalRequestDTO
89
+ const g = groupApprovals([{ ...base, id: '1', action_key: 'cancel_fiscal' }, { ...base, id: '2', action_key: 'approve_adjustment' }])
90
+ expect(Object.keys(g).sort()).toEqual(['cfdi', 'other'])
91
+ off()
92
+ })
93
+ })
94
+
95
+ describe('allocatePayment (estrategias y bordes)', () => {
96
+ it('oldest_issued_first ordena por emisión', () => {
97
+ const r = allocatePayment(50000, docs, 'oldest_issued_first')
98
+ expect(r.allocations.map(a => a.document_id)).toEqual(['a1051', 'a1060'])
99
+ })
100
+ it('monto negativo o cero no aplica nada y validateAllocation lo marca', () => {
101
+ expect(allocatePayment(-5, docs)).toEqual({ allocations: [], applied: 0, unapplied: 0 })
102
+ expect(validateAllocation(0, 'MXN', docs, []).map(i => i.code)).toContain('non_positive')
103
+ })
104
+ it('pago mayor a todos los saldos deja el excedente sin aplicar', () => {
105
+ const r = allocatePayment(100000, docs)
106
+ expect(r.applied).toBe(74030.4)
107
+ expect(r.unapplied).toBe(25969.6)
108
+ })
109
+ })
110
+
111
+ describe('bucketFor', () => {
112
+ it('ubica los bordes de cada tramo', () => {
113
+ expect(bucketFor(-3)).toBe('current')
114
+ expect(bucketFor(0)).toBe('current')
115
+ expect(bucketFor(30)).toBe('d1_30')
116
+ expect(bucketFor(31)).toBe('d31_60')
117
+ expect(bucketFor(400)).toBe('d90_plus')
118
+ })
119
+ })
120
+
121
+ describe('contribuciones federadas (registro)', () => {
122
+ afterEach(() => __resetContributions())
123
+ const ctx = { kind: 'invoice', model: 'customers.Invoice', values: { total: 10 }, mode: 'edit' as const }
124
+
125
+ it('publica en slotRegistry por kind/region y el disposer la retira', () => {
126
+ const off = registerDocumentContribution({ id: 'a.body.x', kinds: ['invoice', 'quote'], region: 'body.sections', component: () => null })
127
+ expect(slotRegistry.get(slotIdFor('invoice', 'body.sections'))).toHaveLength(1)
128
+ expect(slotRegistry.get(slotIdFor('quote', 'body.sections'))).toHaveLength(1)
129
+ off()
130
+ off() // idempotente
131
+ expect(slotRegistry.get(slotIdFor('invoice', 'body.sections'))).toHaveLength(0)
132
+ })
133
+
134
+ it('re-registrar el mismo id reemplaza; el disposer viejo no borra el nuevo', () => {
135
+ const offOld = registerDocumentContribution({ id: 'a.side.p', kinds: '*', region: 'side.panels', priority: 1, component: () => null })
136
+ registerDocumentContribution({ id: 'a.side.p', kinds: '*', region: 'side.panels', priority: 5, component: () => null })
137
+ expect(slotRegistry.get(slotIdFor('*', 'side.panels'))).toHaveLength(1)
138
+ offOld()
139
+ expect(resolveContributions(ctx, 'side.panels', () => true).map(c => c.priority)).toEqual([5])
140
+ })
141
+
142
+ it('filtra por `when` y por kind; con `load` siempre expone un componente', () => {
143
+ registerDocumentContribution({ id: 'a.h.only_edit', kinds: ['invoice'], region: 'header.fields', when: c => c.mode === 'edit', load: async () => ({ default: () => null }) })
144
+ registerDocumentContribution({ id: 'a.h.quote', kinds: ['quote'], region: 'header.fields', component: () => null })
145
+ const [only] = resolveContributions(ctx, 'header.fields', () => true)
146
+ expect(only.id).toBe('a.h.only_edit')
147
+ expect(typeof only.component).toBe('function')
148
+ expect(resolveContributions({ ...ctx, mode: 'create' }, 'header.fields', () => true)).toEqual([])
149
+ })
150
+
151
+ it('exige component o load', () => {
152
+ expect(() => registerDocumentContribution({ id: 'bad', kinds: '*', region: 'side.panels' })).toThrow(/component o load/)
153
+ })
154
+
155
+ it('notifica a los suscriptores en register/dispose', () => {
156
+ const spy = vi.fn()
157
+ const unsub = subscribeContributions(spy)
158
+ const v0 = contributionsVersion()
159
+ const off = registerDocumentContribution({ id: 'n', kinds: '*', region: 'side.panels', component: () => null })
160
+ off()
161
+ expect(spy).toHaveBeenCalledTimes(2)
162
+ expect(contributionsVersion()).toBe(v0 + 2)
163
+ unsub()
164
+ })
165
+
166
+ it('modal federado: no registrado → null; disposer lo retira; componente memoizado', () => {
167
+ expect(resolveFederatedModal('returns.settle_method', () => true)).toBeNull()
168
+ const m = { key: 'returns.settle_method', addon: 'returns', load: async () => ({ default: () => null }) }
169
+ const off = registerFederatedModal(m)
170
+ expect(resolveFederatedModal('returns.settle_method', () => true)).toBe(m)
171
+ expect(federatedModalComponent(m)).toBe(federatedModalComponent(m))
172
+ off()
173
+ expect(resolveFederatedModal('returns.settle_method', () => true)).toBeNull()
174
+ })
175
+
176
+ it('installedPredicate: sin provider = instalado; con provider consulta el set', () => {
177
+ expect(installedPredicate(null)('lo_que_sea')).toBe(true)
178
+ const p = installedPredicate({ addons: new Set(['customers']), capabilities: new Set() })
179
+ expect(p('customers')).toBe(true)
180
+ expect(p('fiscal_mexico')).toBe(false)
181
+ })
182
+ })
183
+
184
+ describe('tipos de documento', () => {
185
+ it('registerDocumentKind con disposer y filtrado por addon instalado', () => {
186
+ const offs = CORE_DOCUMENT_KINDS.map(registerDocumentKind)
187
+ expect(getDocumentKind('invoice')?.model).toBe('customers.Invoice')
188
+ expect(listDocumentKinds(a => a === 'purchases').map(k => k.key)).toEqual(['purchase_order', 'supplier_invoice', 'supplier_payment'])
189
+ offs.forEach(off => off())
190
+ expect(getDocumentKind('invoice')).toBeUndefined()
191
+ })
192
+ })
193
+
194
+ describe('paneles', () => {
195
+ it('blockingChecks: solo errores sin política de autorización', () => {
196
+ const checks = [
197
+ { key: 'a', severity: 'error' as const, message: 'Falta RFC' },
198
+ { key: 'b', severity: 'error' as const, message: 'Sin crédito', approval_policy: 'credit_override' },
199
+ { key: 'c', severity: 'warning' as const, message: 'Sin correo' },
200
+ ]
201
+ expect(blockingChecks(checks).map(c => c.key)).toEqual(['a'])
202
+ })
203
+ })
204
+
205
+ describe('approvalsClient', () => {
206
+ it('arma la consulta y lee lista y conteo', async () => {
207
+ const get = vi.fn(async (url: string) =>
208
+ url.startsWith('/approvals/count') ? { data: { data: { count: 3 } } } : { data: { data: [{ id: '1' }], meta: { total: 1 } } },
209
+ )
210
+ const post = vi.fn(async () => ({ data: {} }))
211
+ const c = approvalsClient({ get, post } as unknown as ApiClient)
212
+ expect(await c.list({ status: 'pending', for_me: true, model: undefined })).toEqual({ items: [{ id: '1' }], total: 1 })
213
+ expect(get).toHaveBeenCalledWith('/approvals?status=pending&for_me=true')
214
+ expect(await c.count()).toBe(3)
215
+ await c.reject('9', 'No procede')
216
+ expect(post).toHaveBeenCalledWith('/approvals/9/reject', { reason: 'No procede' })
217
+ })
218
+ it('parseJSONField tolera string, objeto y basura', () => {
219
+ expect(parseJSONField('{"a":1}')).toEqual({ a: 1 })
220
+ expect(parseJSONField({ a: 2 })).toEqual({ a: 2 })
221
+ expect(parseJSONField('no-json')).toBeNull()
222
+ expect(parseJSONField('')).toBeNull()
223
+ })
224
+ })
@@ -0,0 +1,50 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // RecordPrefillBar: pinta los ayudantes registrados para el modelo/modo, los
4
+ // filtra por permiso y solo deja pasar al formulario las claves que declara.
5
+ import { afterEach, describe, expect, it, vi } from 'vitest'
6
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
7
+ import { registerRecordPrefill, unregisterRecordPrefillsByOwner, type RecordPrefillProps } from '@asteby/metacore-sdk'
8
+ import { RecordPrefillBar } from '../record-prefill-bar'
9
+ import { PermissionsProvider } from '../permissions-context'
10
+
11
+ afterEach(() => {
12
+ cleanup()
13
+ unregisterRecordPrefillsByOwner('fiscal_mexico')
14
+ })
15
+
16
+ function CsfButton({ applyValues }: RecordPrefillProps) {
17
+ return (
18
+ <button type="button" onClick={() => applyValues({ tax_id: 'XAXX010101000', organization_id: 'hack' })}>
19
+ Cargar constancia
20
+ </button>
21
+ )
22
+ }
23
+
24
+ describe('RecordPrefillBar', () => {
25
+ it('aplica solo claves permitidas y aparece cuando el remote registra tarde', () => {
26
+ const onApply = vi.fn()
27
+ render(
28
+ <RecordPrefillBar model="customers.Customer" mode="create" values={{}} allowedKeys={new Set(['tax_id'])} onApply={onApply} />,
29
+ )
30
+ expect(screen.queryByText('Cargar constancia')).toBeNull()
31
+ act(() => {
32
+ registerRecordPrefill({ id: 'fiscal_mexico.csf', models: ['Customer'], component: CsfButton }, 'fiscal_mexico')
33
+ })
34
+ fireEvent.click(screen.getByText('Cargar constancia'))
35
+ expect(onApply).toHaveBeenCalledWith({ tax_id: 'XAXX010101000' })
36
+ })
37
+
38
+ it('se oculta sin el permiso declarado', () => {
39
+ registerRecordPrefill(
40
+ { id: 'fiscal_mexico.csf', models: ['Customer'], component: CsfButton, permission: 'customers.csf' },
41
+ 'fiscal_mexico',
42
+ )
43
+ render(
44
+ <PermissionsProvider permissions={[]} isAdmin={false}>
45
+ <RecordPrefillBar model="Customer" mode="create" values={{}} allowedKeys={new Set(['tax_id'])} onApply={() => {}} />
46
+ </PermissionsProvider>,
47
+ )
48
+ expect(screen.queryByText('Cargar constancia')).toBeNull()
49
+ })
50
+ })
@@ -15,7 +15,7 @@
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
17
  import { emitRecordMutation } from './record-mutation-events'
18
- import { useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
18
+ import { Suspense, useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
19
19
  import { useTranslation } from 'react-i18next'
20
20
  import {
21
21
  Dialog,
@@ -88,6 +88,9 @@ import {
88
88
  subscribeActionComponents,
89
89
  } from '@asteby/metacore-sdk'
90
90
  import { requestFederatedAction } from './federated-action-loader'
91
+ import { useInstalledAddons } from './installed-addons-context'
92
+ import { federatedModalComponent, resolveFederatedModal } from './primitives/contributions'
93
+ import { installedPredicate, useContributionsVersion } from './primitives/use-contributions'
91
94
 
92
95
  export type { ActionMetadata, ActionModalProps }
93
96
 
@@ -375,6 +378,15 @@ export function ActionModalDispatcher({
375
378
  const readComponent = () => getActionComponent(model, action.key)
376
379
  const CustomComponent = useSyncExternalStore(subscribeActionComponents, readComponent, readComponent)
377
380
 
381
+ // Modal federado declarativo (registerFederatedModal). Si el addon dueño
382
+ // del slug consta como NO instalado, el `modal` se ignora y se usa el
383
+ // formulario genérico de la acción de inmediato (sin esperar el timeout).
384
+ useContributionsVersion()
385
+ const installed = useInstalledAddons()
386
+ const slug = action.modal || ''
387
+ const federatedModal = slug ? resolveFederatedModal(slug, installedPredicate(installed)) : null
388
+ const modalAddonMissing = !!slug && !federatedModal && !!installed && !installed.addons.has(slug.split('.')[0])
389
+
378
390
  if (CustomComponent) {
379
391
  return (
380
392
  <CustomComponent
@@ -389,10 +401,28 @@ export function ActionModalDispatcher({
389
401
  )
390
402
  }
391
403
 
404
+ if (federatedModal) {
405
+ const FederatedComponent = federatedModalComponent<ActionModalProps>(federatedModal)
406
+ return (
407
+ <Suspense fallback={<LoadingActionDialog open={open} onOpenChange={onOpenChange} action={action} />}>
408
+ <FederatedComponent
409
+ open={open}
410
+ onOpenChange={onOpenChange}
411
+ action={action}
412
+ model={model}
413
+ record={record}
414
+ endpoint={endpoint}
415
+ onSuccess={onSuccess}
416
+ />
417
+ </Suspense>
418
+ )
419
+ }
420
+
392
421
  // Declarative custom slot (`modal: "addon.action"`). Wait for the remote,
393
422
  // then a hard error — confirm/fields generics look "fine" and hide a
394
- // missing remote.
395
- if (action.modal) {
423
+ // missing remote. Exception: the slug's addon is known NOT installed — the
424
+ // remote will never come, so fall through to the generic form now.
425
+ if (action.modal && !modalAddonMissing) {
396
426
  return (
397
427
  <PendingCustomActionModal
398
428
  open={open}
@@ -479,7 +509,6 @@ function PendingCustomActionModal({
479
509
  action: ActionMetadata
480
510
  model: string
481
511
  }) {
482
- const { t } = useTranslation()
483
512
  const slug = action.modal || ''
484
513
  const [timedOut, setTimedOut] = useState(false)
485
514
 
@@ -522,6 +551,20 @@ function PendingCustomActionModal({
522
551
  return <MissingCustomActionModal open={open} onOpenChange={onOpenChange} action={action} model={model} />
523
552
  }
524
553
 
554
+ return <LoadingActionDialog open={open} onOpenChange={onOpenChange} action={action} />
555
+ }
556
+
557
+ /** Diálogo «Cargando…» mientras llega el componente federado de la acción. */
558
+ function LoadingActionDialog({
559
+ open,
560
+ onOpenChange,
561
+ action,
562
+ }: {
563
+ open: boolean
564
+ onOpenChange: (open: boolean) => void
565
+ action: ActionMetadata
566
+ }) {
567
+ const { t } = useTranslation()
525
568
  return (
526
569
  <Dialog open={open} onOpenChange={onOpenChange}>
527
570
  <DialogContent>
@@ -0,0 +1,87 @@
1
+ // createCatalogProductSearch — buscador de productos POR DEFECTO para
2
+ // DocumentLinesGrid cuando el host no inyecta uno (`searchProducts`).
3
+ //
4
+ // Problema que corrige: DynamicCRUDPage monta DocumentFormDialog SIN
5
+ // `searchProducts`, así que el paso de renglones de «Crear factura» solo ofrecía
6
+ // «Renglón libre» (descripción a mano, precio 0, IVA 0) y la factura se guardaba
7
+ // en $0. Con esto, cualquier documento declarativo busca en el catálogo.
8
+ //
9
+ // Lee el listado canónico GET /data/<model>?search=&per_page= (el mismo que usa
10
+ // DynamicTable). No usa /options: su envelope solo trae id/label, sin precio.
11
+ import type { ProductQuery, ProductResult } from './product-search'
12
+
13
+ interface ApiLike {
14
+ get: (url: string, cfg?: { params?: Record<string, unknown>; signal?: AbortSignal }) => Promise<{ data: any }>
15
+ }
16
+
17
+ export interface CatalogProductSearchOptions {
18
+ /** Modelo de catálogo. Default `products.Product`. */
19
+ model?: string
20
+ /** Endpoint de listado. Default `/data/<model>`. */
21
+ endpoint?: string
22
+ limit?: number
23
+ /** Tasa de impuesto cuando el producto no trae la suya (config fiscal de la org; p. ej. 0.16). */
24
+ defaultTaxRate?: number
25
+ }
26
+
27
+ const num = (v: unknown): number | undefined => {
28
+ const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : typeof v === 'number' ? v : NaN
29
+ return Number.isFinite(n) ? n : undefined
30
+ }
31
+
32
+ const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
33
+
34
+ /** Tasa como fracción: acepta 0.16 o 16. */
35
+ const rate = (v: unknown): number | undefined => {
36
+ const n = num(v)
37
+ if (n == null || n < 0) return undefined
38
+ return n > 1 ? n / 100 : n
39
+ }
40
+
41
+ const bag = (v: unknown): Record<string, unknown> => {
42
+ if (typeof v === 'string') {
43
+ try {
44
+ const parsed = JSON.parse(v)
45
+ return parsed && typeof parsed === 'object' ? parsed : {}
46
+ } catch {
47
+ return {}
48
+ }
49
+ }
50
+ return v && typeof v === 'object' ? (v as Record<string, unknown>) : {}
51
+ }
52
+
53
+ /** Proyección pura registro del catálogo → ProductResult (exportada para tests). */
54
+ export function catalogRecordToProduct(rec: Record<string, any>, defaultTaxRate?: number): ProductResult {
55
+ // Campos de extensión de otros addons (p. ej. claves SAT de fiscal_mexico).
56
+ const extensions = Object.fromEntries(
57
+ Object.entries(bag(rec.fiscal_data)).filter(([, v]) => typeof v === 'string' && v !== ''),
58
+ ) as Record<string, string>
59
+ const pt = String(rec.product_type ?? '')
60
+ return {
61
+ id: String(rec.id ?? rec.value ?? ''),
62
+ name: String(rec.name ?? rec.label ?? ''),
63
+ sku: str(rec.sku),
64
+ barcode: str(rec.barcode),
65
+ kind: pt === 'service' ? 'service' : 'product',
66
+ price: num(rec.unit_price ?? rec.sale_price ?? rec.price),
67
+ cost: num(rec.cost_price ?? rec.cost),
68
+ tax_rate: rate(rec.tax_rate) ?? defaultTaxRate,
69
+ unit: str(rec.unit_of_measure) ?? str(rec.unit),
70
+ extensions: Object.keys(extensions).length ? extensions : undefined,
71
+ }
72
+ }
73
+
74
+ export function createCatalogProductSearch(api: ApiLike, opts: CatalogProductSearchOptions = {}) {
75
+ const model = opts.model ?? 'products.Product'
76
+ const url = opts.endpoint ?? `/data/${model}`
77
+ const limit = opts.limit ?? 20
78
+ return async (query: Exclude<ProductQuery, { kind: 'empty' }>, signal: AbortSignal): Promise<ProductResult[]> => {
79
+ const search = query.kind === 'barcode' ? query.barcode : query.raw.trim()
80
+ const res = await api.get(url, { params: { search, per_page: limit }, signal })
81
+ const rows: unknown = res?.data?.data
82
+ if (!Array.isArray(rows)) return []
83
+ return rows
84
+ .filter((r) => r && typeof r === 'object' && (r as Record<string, unknown>).is_active !== false)
85
+ .map((r) => catalogRecordToProduct(r as Record<string, any>, opts.defaultTaxRate))
86
+ }
87
+ }
@@ -51,6 +51,8 @@ export const BUSINESS_COMPONENTS: readonly BusinessComponentSpec[] = [
51
51
  'applyProductToLine',
52
52
  'addProductLine',
53
53
  'lineGridKeyCommand',
54
+ 'createCatalogProductSearch',
55
+ 'catalogRecordToProduct',
54
56
  ],
55
57
  },
56
58
  {