@asteby/metacore-runtime-react 47.0.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 (178) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  3. package/dist/action-modal-dispatcher.js +24 -4
  4. package/dist/audit-info.d.ts +54 -0
  5. package/dist/audit-info.d.ts.map +1 -0
  6. package/dist/audit-info.js +142 -0
  7. package/dist/business/catalog-product-search.d.ts +25 -0
  8. package/dist/business/catalog-product-search.d.ts.map +1 -0
  9. package/dist/business/catalog-product-search.js +57 -0
  10. package/dist/business/catalog.d.ts.map +1 -1
  11. package/dist/business/catalog.js +2 -0
  12. package/dist/business/document-editor-model.d.ts +107 -0
  13. package/dist/business/document-editor-model.d.ts.map +1 -0
  14. package/dist/business/document-editor-model.js +279 -0
  15. package/dist/business/document-editor-panels.d.ts +64 -0
  16. package/dist/business/document-editor-panels.d.ts.map +1 -0
  17. package/dist/business/document-editor-panels.js +58 -0
  18. package/dist/business/document-editor.d.ts +27 -0
  19. package/dist/business/document-editor.d.ts.map +1 -0
  20. package/dist/business/document-editor.js +445 -0
  21. package/dist/business/document-lines.d.ts.map +1 -1
  22. package/dist/business/document-lines.js +5 -1
  23. package/dist/business/index.d.ts +5 -0
  24. package/dist/business/index.d.ts.map +1 -1
  25. package/dist/business/index.js +5 -0
  26. package/dist/business/line-items.d.ts +7 -0
  27. package/dist/business/line-items.d.ts.map +1 -1
  28. package/dist/business/line-items.js +6 -1
  29. package/dist/business/payment-allocator.d.ts +19 -0
  30. package/dist/business/payment-allocator.d.ts.map +1 -0
  31. package/dist/business/payment-allocator.js +31 -0
  32. package/dist/business/product-search.d.ts +4 -0
  33. package/dist/business/product-search.d.ts.map +1 -1
  34. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  35. package/dist/dialogs/dynamic-record.js +19 -8
  36. package/dist/document-form-dialog.d.ts +31 -2
  37. package/dist/document-form-dialog.d.ts.map +1 -1
  38. package/dist/document-form-dialog.js +43 -11
  39. package/dist/dynamic-columns-helpers.d.ts +9 -0
  40. package/dist/dynamic-columns-helpers.d.ts.map +1 -1
  41. package/dist/dynamic-columns-helpers.js +19 -0
  42. package/dist/dynamic-columns.d.ts +8 -2
  43. package/dist/dynamic-columns.d.ts.map +1 -1
  44. package/dist/dynamic-columns.js +21 -5
  45. package/dist/dynamic-crud-page.d.ts +6 -0
  46. package/dist/dynamic-crud-page.d.ts.map +1 -1
  47. package/dist/dynamic-crud-page.js +14 -5
  48. package/dist/dynamic-form-schema.d.ts.map +1 -1
  49. package/dist/dynamic-form-schema.js +15 -3
  50. package/dist/dynamic-form.d.ts +16 -1
  51. package/dist/dynamic-form.d.ts.map +1 -1
  52. package/dist/dynamic-form.js +81 -20
  53. package/dist/dynamic-kanban.d.ts +9 -3
  54. package/dist/dynamic-kanban.d.ts.map +1 -1
  55. package/dist/dynamic-kanban.js +39 -13
  56. package/dist/dynamic-line-items.d.ts.map +1 -1
  57. package/dist/dynamic-line-items.js +3 -0
  58. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  59. package/dist/dynamic-multi-select-field.js +2 -0
  60. package/dist/dynamic-relation-helpers.d.ts.map +1 -1
  61. package/dist/dynamic-relation-helpers.js +8 -0
  62. package/dist/dynamic-relation.js +1 -1
  63. package/dist/dynamic-select-field.d.ts.map +1 -1
  64. package/dist/dynamic-select-field.js +4 -0
  65. package/dist/index.d.ts +8 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +17 -2
  68. package/dist/installed-addons-context.d.ts +15 -0
  69. package/dist/installed-addons-context.d.ts.map +1 -0
  70. package/dist/installed-addons-context.js +39 -0
  71. package/dist/option-filter.d.ts +31 -0
  72. package/dist/option-filter.d.ts.map +1 -0
  73. package/dist/option-filter.js +49 -0
  74. package/dist/org-runtime-context.d.ts +8 -0
  75. package/dist/org-runtime-context.d.ts.map +1 -1
  76. package/dist/org-runtime-context.js +8 -0
  77. package/dist/org-runtime-provider.d.ts +3 -1
  78. package/dist/org-runtime-provider.d.ts.map +1 -1
  79. package/dist/org-runtime-provider.js +3 -3
  80. package/dist/primitives/aging.d.ts +34 -0
  81. package/dist/primitives/aging.d.ts.map +1 -0
  82. package/dist/primitives/aging.js +48 -0
  83. package/dist/primitives/allocation.d.ts +42 -0
  84. package/dist/primitives/allocation.d.ts.map +1 -0
  85. package/dist/primitives/allocation.js +59 -0
  86. package/dist/primitives/approvals.d.ts +71 -0
  87. package/dist/primitives/approvals.d.ts.map +1 -0
  88. package/dist/primitives/approvals.js +48 -0
  89. package/dist/primitives/contributions.d.ts +82 -0
  90. package/dist/primitives/contributions.d.ts.map +1 -0
  91. package/dist/primitives/contributions.js +126 -0
  92. package/dist/primitives/document-kinds.d.ts +25 -0
  93. package/dist/primitives/document-kinds.d.ts.map +1 -0
  94. package/dist/primitives/document-kinds.js +26 -0
  95. package/dist/primitives/index.d.ts +8 -0
  96. package/dist/primitives/index.d.ts.map +1 -0
  97. package/dist/primitives/index.js +23 -0
  98. package/dist/primitives/panels.d.ts +68 -0
  99. package/dist/primitives/panels.d.ts.map +1 -0
  100. package/dist/primitives/panels.js +3 -0
  101. package/dist/primitives/use-contributions.d.ts +15 -0
  102. package/dist/primitives/use-contributions.d.ts.map +1 -0
  103. package/dist/primitives/use-contributions.js +30 -0
  104. package/dist/record-prefill-bar.d.ts +11 -0
  105. package/dist/record-prefill-bar.d.ts.map +1 -0
  106. package/dist/record-prefill-bar.js +33 -0
  107. package/dist/types.d.ts +142 -0
  108. package/dist/types.d.ts.map +1 -1
  109. package/dist/use-options-resolver.d.ts +8 -0
  110. package/dist/use-options-resolver.d.ts.map +1 -1
  111. package/dist/use-options-resolver.js +8 -2
  112. package/package.json +5 -5
  113. package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
  114. package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
  115. package/src/__tests__/audit-info.test.tsx +103 -0
  116. package/src/__tests__/catalog-product-search.test.ts +61 -0
  117. package/src/__tests__/dependent-options.test.tsx +12 -5
  118. package/src/__tests__/document-editor-model.test.ts +167 -0
  119. package/src/__tests__/document-editor.test.tsx +261 -0
  120. package/src/__tests__/document-form-dialog.test.tsx +118 -3
  121. package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
  122. package/src/__tests__/dynamic-kanban.test.tsx +24 -3
  123. package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
  124. package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
  125. package/src/__tests__/installed-addons-context.test.tsx +50 -0
  126. package/src/__tests__/line-item-totals.test.ts +28 -0
  127. package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
  128. package/src/__tests__/option-filter.test.tsx +115 -0
  129. package/src/__tests__/primitives-federated.test.tsx +156 -0
  130. package/src/__tests__/primitives.test.ts +224 -0
  131. package/src/__tests__/record-prefill-bar.test.tsx +50 -0
  132. package/src/__tests__/structured-view-value.test.tsx +5 -1
  133. package/src/__tests__/wizard-action-modal.test.tsx +1 -1
  134. package/src/action-modal-dispatcher.tsx +47 -4
  135. package/src/audit-info.tsx +219 -0
  136. package/src/business/catalog-product-search.ts +87 -0
  137. package/src/business/catalog.ts +2 -0
  138. package/src/business/document-editor-model.ts +325 -0
  139. package/src/business/document-editor-panels.tsx +164 -0
  140. package/src/business/document-editor.tsx +727 -0
  141. package/src/business/document-lines.ts +5 -1
  142. package/src/business/index.ts +5 -0
  143. package/src/business/line-items.ts +15 -1
  144. package/src/business/payment-allocator.tsx +110 -0
  145. package/src/business/product-search.ts +4 -0
  146. package/src/dialogs/dynamic-record.tsx +42 -6
  147. package/src/document-form-dialog.tsx +92 -12
  148. package/src/dropdown-menu-entry.test.ts +23 -0
  149. package/src/dynamic-columns-helpers.ts +22 -0
  150. package/src/dynamic-columns.tsx +23 -6
  151. package/src/dynamic-crud-page.tsx +22 -3
  152. package/src/dynamic-form-schema.ts +15 -2
  153. package/src/dynamic-form.tsx +120 -39
  154. package/src/dynamic-kanban.tsx +41 -10
  155. package/src/dynamic-line-items.tsx +3 -0
  156. package/src/dynamic-multi-select-field.tsx +2 -0
  157. package/src/dynamic-relation-helpers.ts +9 -0
  158. package/src/dynamic-relation.tsx +7 -2
  159. package/src/dynamic-select-field.tsx +4 -0
  160. package/src/index.ts +34 -1
  161. package/src/installed-addons-context.tsx +58 -0
  162. package/src/option-filter.ts +88 -0
  163. package/src/org-runtime-context.ts +10 -0
  164. package/src/org-runtime-provider.tsx +7 -2
  165. package/src/primitives/aging.ts +76 -0
  166. package/src/primitives/allocation.ts +104 -0
  167. package/src/primitives/approvals.ts +103 -0
  168. package/src/primitives/contributions.ts +208 -0
  169. package/src/primitives/document-kinds.ts +62 -0
  170. package/src/primitives/index.ts +23 -0
  171. package/src/primitives/panels.ts +59 -0
  172. package/src/primitives/use-contributions.ts +42 -0
  173. package/src/record-prefill-bar.tsx +62 -0
  174. package/src/row-action-stage-gate.test.ts +33 -0
  175. package/src/types.ts +142 -0
  176. package/src/use-options-resolver.ts +19 -1
  177. package/vitest.config.ts +1 -0
  178. package/vitest.setup.ts +7 -0
@@ -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
+ })
@@ -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
 
@@ -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'
@@ -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,219 @@
1
+ /**
2
+ * audit-info.tsx
3
+ *
4
+ * <AuditInfo> — discreet "when and who" footer for a record: created, modified
5
+ * and (if tombstoned) deleted, each with the actor and a relative + absolute
6
+ * date. Driven only by the kernel's `TableMetadata.audit` (column names) and
7
+ * the record row — manifests declare nothing.
8
+ *
9
+ * Actor resolution (no extra requests), in order:
10
+ * 1. the host's `resolveActor(id)` when it returns a name;
11
+ * 2. the record's expanded sibling (`created_by_id` → `row.created_by`,
12
+ * `{ label | name | title }` or a plain string);
13
+ * 3. the system actor id → "Sistema" / "System";
14
+ * 4. a short id (first 8 chars) — never a long uuid.
15
+ *
16
+ * Renders nothing when `audit` is absent or the record has none of its values.
17
+ */
18
+ import * as React from 'react'
19
+ import { useTranslation } from 'react-i18next'
20
+ import { formatDistanceToNow } from 'date-fns'
21
+ import { es, enUS } from 'date-fns/locale'
22
+ import { ChevronDown, ChevronRight, History } from 'lucide-react'
23
+ import { cn } from '@asteby/metacore-ui/lib'
24
+ import { formatDateCell } from './dynamic-columns'
25
+ import { objectLabel } from './dynamic-relation-helpers'
26
+ import { isNilUuid } from './nil-uuid'
27
+ import { useTimeZone } from './org-runtime-context'
28
+ import type { AuditMeta } from './types'
29
+
30
+ /** The kernel's actor for unattended work (schedules, webhooks, connectors). */
31
+ export const SYSTEM_ACTOR_ID = '00000000-0000-0000-0000-000000000001'
32
+
33
+ const AUDIT_KEYS: (keyof AuditMeta)[] = [
34
+ 'created_at',
35
+ 'created_by',
36
+ 'updated_at',
37
+ 'updated_by',
38
+ 'deleted_at',
39
+ 'deleted_by',
40
+ ]
41
+
42
+ /**
43
+ * Defensive reader for the served `audit` block: keeps only known keys whose
44
+ * value is a non-empty string; returns `undefined` when nothing usable remains.
45
+ */
46
+ export function readAuditMeta(raw: unknown): AuditMeta | undefined {
47
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
48
+ const src = raw as Record<string, unknown>
49
+ const out: AuditMeta = {}
50
+ for (const k of AUDIT_KEYS) {
51
+ const v = src[k]
52
+ if (typeof v === 'string' && v.trim() !== '') out[k] = v
53
+ }
54
+ return Object.keys(out).length ? out : undefined
55
+ }
56
+
57
+ export interface AuditActor {
58
+ /** Display text. */
59
+ name: string
60
+ /** True when the name is a real resolved label (not a short id). */
61
+ resolved: boolean
62
+ }
63
+
64
+ /** Resolves the actor stored in `row[idColumn]` to something human. */
65
+ export function resolveAuditActor(
66
+ row: Record<string, unknown>,
67
+ idColumn: string | undefined,
68
+ opts: { resolveActor?: (id: string) => string | undefined; systemLabel: string },
69
+ ): AuditActor | null {
70
+ if (!idColumn) return null
71
+ const raw = row[idColumn]
72
+ // The column itself may already be an expanded object.
73
+ const own = objectLabel(raw)
74
+ const id = typeof raw === 'string' ? raw.trim() : ''
75
+ if (!own && (!id || isNilUuid(id))) return null
76
+ if (id.toLowerCase() === SYSTEM_ACTOR_ID) return { name: opts.systemLabel, resolved: true }
77
+ if (id) {
78
+ const fromHost = opts.resolveActor?.(id)
79
+ if (fromHost) return { name: fromHost, resolved: true }
80
+ }
81
+ if (own) return { name: own, resolved: true }
82
+ const base = idColumn.endsWith('_id') ? idColumn.slice(0, -3) : idColumn
83
+ const sibling = base === idColumn ? undefined : row[base]
84
+ const siblingName =
85
+ objectLabel(sibling) ?? (typeof sibling === 'string' && sibling.trim() !== '' ? sibling : undefined)
86
+ if (siblingName) return { name: siblingName, resolved: true }
87
+ return { name: id.length > 8 ? id.slice(0, 8) : id, resolved: false }
88
+ }
89
+
90
+ const COPY = {
91
+ es: {
92
+ title: 'Historial del registro',
93
+ created: 'Creado por {{who}}',
94
+ updated: 'Modificado por {{who}}',
95
+ deleted: 'Eliminado por {{who}}',
96
+ createdAt: 'Creado',
97
+ updatedAt: 'Modificado',
98
+ deletedAt: 'Eliminado',
99
+ system: 'Sistema',
100
+ },
101
+ en: {
102
+ title: 'Record history',
103
+ created: 'Created by {{who}}',
104
+ updated: 'Modified by {{who}}',
105
+ deleted: 'Deleted by {{who}}',
106
+ createdAt: 'Created',
107
+ updatedAt: 'Modified',
108
+ deletedAt: 'Deleted',
109
+ system: 'System',
110
+ },
111
+ } as const
112
+
113
+ export interface AuditInfoProps {
114
+ /** The record row (keys are the column names `audit` points at). */
115
+ record: Record<string, unknown> | null | undefined
116
+ /** `TableMetadata.audit`. Absent → renders nothing. */
117
+ audit: AuditMeta | null | undefined
118
+ /** Optional host resolver uuid → display name (e.g. from a users cache). */
119
+ resolveActor?: (id: string) => string | undefined
120
+ /** IANA timezone; defaults to the org runtime timezone. */
121
+ timeZone?: string
122
+ /** BCP-47 locale; defaults to the i18n language. */
123
+ locale?: string
124
+ /** Start expanded. Default: collapsed. */
125
+ defaultOpen?: boolean
126
+ className?: string
127
+ }
128
+
129
+ interface Line {
130
+ key: string
131
+ text: string
132
+ absolute: string
133
+ title?: string
134
+ relative: string
135
+ }
136
+
137
+ export function AuditInfo({
138
+ record,
139
+ audit,
140
+ resolveActor,
141
+ timeZone,
142
+ locale,
143
+ defaultOpen = false,
144
+ className,
145
+ }: AuditInfoProps) {
146
+ const { t, i18n } = useTranslation()
147
+ const ctxTz = useTimeZone()
148
+ const [open, setOpen] = React.useState(defaultOpen)
149
+
150
+ const meta = readAuditMeta(audit)
151
+ if (!meta || !record) return null
152
+
153
+ const lang = (locale || i18n?.language || 'es').toLowerCase().startsWith('en') ? 'en' : 'es'
154
+ const copy = COPY[lang]
155
+ const dfLocale = lang === 'en' ? enUS : es
156
+ const tz = timeZone ?? ctxTz
157
+ const tt = (key: keyof typeof copy, vars?: Record<string, string>) =>
158
+ t(`audit.${key}`, { defaultValue: copy[key], ...vars }) as string
159
+
160
+ const build = (
161
+ key: string,
162
+ atCol: string | undefined,
163
+ byCol: string | undefined,
164
+ whoKey: 'created' | 'updated' | 'deleted',
165
+ whenKey: 'createdAt' | 'updatedAt' | 'deletedAt',
166
+ ): Line | null => {
167
+ const f = atCol ? formatDateCell(record[atCol], undefined, dfLocale, tz) : null
168
+ if (!f) return null
169
+ const actor = resolveAuditActor(record, byCol, { resolveActor, systemLabel: tt('system') })
170
+ const date = new Date(record[atCol as string] as string)
171
+ const relative = formatDistanceToNow(date, { addSuffix: true, locale: dfLocale })
172
+ return {
173
+ key,
174
+ text: actor ? tt(whoKey, { who: actor.name }) : tt(whenKey),
175
+ absolute: f.display,
176
+ title: f.title,
177
+ relative,
178
+ }
179
+ }
180
+
181
+ const lines = [
182
+ build('created', meta.created_at, meta.created_by, 'created', 'createdAt'),
183
+ // Hide "modified" when it is the creation instant itself (never edited).
184
+ meta.updated_at && record[meta.updated_at] !== record[meta.created_at ?? '']
185
+ ? build('updated', meta.updated_at, meta.updated_by, 'updated', 'updatedAt')
186
+ : null,
187
+ build('deleted', meta.deleted_at, meta.deleted_by, 'deleted', 'deletedAt'),
188
+ ].filter((l): l is Line => l !== null)
189
+
190
+ if (lines.length === 0) return null
191
+
192
+ const Chevron = open ? ChevronDown : ChevronRight
193
+ return (
194
+ <div className={cn('text-xs text-muted-foreground', className)} data-testid="audit-info">
195
+ <button
196
+ type="button"
197
+ className="flex items-center gap-1.5 hover:text-foreground"
198
+ aria-expanded={open}
199
+ onClick={() => setOpen(o => !o)}
200
+ >
201
+ <Chevron className="h-3.5 w-3.5" />
202
+ <History className="h-3.5 w-3.5" />
203
+ <span>{tt('title')}</span>
204
+ </button>
205
+ {open && (
206
+ <ul className="mt-2 space-y-1 pl-6">
207
+ {lines.map(l => (
208
+ <li key={l.key} data-audit-line={l.key}>
209
+ <span>{l.text}</span>
210
+ {' · '}
211
+ <time title={l.title}>{l.absolute}</time>
212
+ <span className="opacity-70"> ({l.relative})</span>
213
+ </li>
214
+ ))}
215
+ </ul>
216
+ )}
217
+ </div>
218
+ )
219
+ }