@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
@@ -12,10 +12,11 @@ vi.mock('react-i18next', () => {
12
12
  return { useTranslation: () => value }
13
13
  })
14
14
 
15
- import { DocumentFormDialog, resolveDocumentForms } from '../document-form-dialog'
15
+ import { DocumentFormDialog, resolveDocumentForms, scopeDocumentFormsToFilter } from '../document-form-dialog'
16
16
  import { DynamicCRUDPage } from '../dynamic-crud-page'
17
17
  import { useMetadataCache } from '../metadata-cache'
18
18
  import { ApiProvider, type ApiClient } from '../api-context'
19
+ import { OrgRuntimeProvider } from '../org-runtime-provider'
19
20
  import type { DocumentFormsManifest } from '../types'
20
21
 
21
22
  afterEach(cleanup)
@@ -128,6 +129,84 @@ describe('DocumentFormDialog', () => {
128
129
  })
129
130
  })
130
131
 
132
+ // Retest Pitsline 05/10: Facturas → Crear guardaba la factura en $0 porque el
133
+ // host (DynamicCRUDPage / ops) no pasa `searchProducts` y el paso de renglones
134
+ // solo ofrecía «Renglón libre» sin precio.
135
+ describe('DocumentFormDialog: buscador de catálogo por defecto', () => {
136
+ const invoiceOnly: DocumentFormsManifest = { ...forms, types: [forms.types[0]] }
137
+ const catalogRow = {
138
+ id: 'prod-1',
139
+ name: 'Llanta 205/55R16',
140
+ sku: 'LL-205',
141
+ unit_price: '1500.00',
142
+ cost_price: '900',
143
+ unit_of_measure: 'pza',
144
+ product_type: 'product',
145
+ fiscal_data: { mx_clave_prod_serv: '25172504', mx_clave_unidad: 'H87' },
146
+ }
147
+
148
+ function mount(opts: { searchProducts?: any; taxRate?: number } = {}) {
149
+ const get = vi.fn().mockResolvedValue({ data: { success: true, data: [catalogRow] } })
150
+ const post = vi.fn().mockResolvedValue({ data: { success: true, data: { id: 'inv-1' } } })
151
+ const client = { get, post, put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
152
+ render(
153
+ <ApiProvider client={client}>
154
+ <OrgRuntimeProvider taxRate={opts.taxRate}>
155
+ <DocumentFormDialog
156
+ open
157
+ onOpenChange={vi.fn()}
158
+ model="customers.Invoice"
159
+ forms={invoiceOnly}
160
+ searchProducts={opts.searchProducts}
161
+ />
162
+ </OrgRuntimeProvider>
163
+ </ApiProvider>,
164
+ )
165
+ return { get, post }
166
+ }
167
+
168
+ async function pickFirstProduct(query: string, name: RegExp) {
169
+ fireEvent.change(document.querySelectorAll('input')[0], { target: { value: 'ACME' } })
170
+ fireEvent.click(screen.getByRole('button', { name: 'Siguiente' }))
171
+ fireEvent.change(await screen.findByLabelText('Buscar producto'), { target: { value: query } })
172
+ fireEvent.click(await waitFor(() => screen.getByRole('button', { name }), { timeout: 2000 }))
173
+ }
174
+
175
+ it('sin searchProducts del host busca en el catálogo y el renglón entra con precio, cantidad 1, unidad, SKU e IVA de la org', async () => {
176
+ const { get, post } = mount({ taxRate: 0.16 })
177
+ await pickFirstProduct('llanta', /Llanta 205\/55R16/)
178
+ expect(get).toHaveBeenCalledWith(
179
+ '/data/products.Product',
180
+ expect.objectContaining({ params: expect.objectContaining({ search: 'llanta' }) }),
181
+ )
182
+ fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
183
+ await waitFor(() => expect(post).toHaveBeenCalled())
184
+ const [line] = (post.mock.calls[0][1] as any).lines
185
+ expect(line).toMatchObject({
186
+ product_id: 'prod-1',
187
+ sku: 'LL-205',
188
+ description: 'Llanta 205/55R16',
189
+ quantity: 1,
190
+ unit_price: 1500,
191
+ tax_rate: 0.16,
192
+ unit: 'pza',
193
+ fiscal_data: { mx_clave_prod_serv: '25172504', mx_clave_unidad: 'H87' },
194
+ })
195
+ expect(line.unit_price).toBeGreaterThan(0)
196
+ })
197
+
198
+ it('si el host pasa searchProducts, se usa ese y no el catálogo', async () => {
199
+ const searchProducts = vi.fn(async () => [{ id: 'h1', name: 'Servicio host', price: 250, tax_rate: 0.08 }])
200
+ const { get, post } = mount({ searchProducts })
201
+ await pickFirstProduct('serv', /Servicio host/)
202
+ expect(searchProducts).toHaveBeenCalled()
203
+ expect(get).not.toHaveBeenCalledWith('/data/products.Product', expect.anything())
204
+ fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
205
+ await waitFor(() => expect(post).toHaveBeenCalled())
206
+ expect((post.mock.calls[0][1] as any).lines[0]).toMatchObject({ product_id: 'h1', quantity: 1, unit_price: 250, tax_rate: 0.08 })
207
+ })
208
+ })
209
+
131
210
  describe('resolveDocumentForms', () => {
132
211
  it('sin manifest (o sin tipos) devuelve undefined: el alta genérica sigue igual', () => {
133
212
  expect(resolveDocumentForms(null)).toBeUndefined()
@@ -157,7 +236,7 @@ describe('DynamicCRUDPage + document_forms', () => {
157
236
  ...extra,
158
237
  }) as any
159
238
 
160
- const mount = (m: any) => {
239
+ const mount = (m: any, filter?: Record<string, string>) => {
161
240
  useMetadataCache.getState().setMetadata('fiscal_documents', m)
162
241
  const client = {
163
242
  get: vi.fn(async (url: string) =>
@@ -169,7 +248,7 @@ describe('DynamicCRUDPage + document_forms', () => {
169
248
  } as unknown as ApiClient
170
249
  render(
171
250
  <ApiProvider client={client}>
172
- <DynamicCRUDPage model="fiscal_documents" />
251
+ <DynamicCRUDPage model="fiscal_documents" filter={filter} />
173
252
  </ApiProvider>,
174
253
  )
175
254
  }
@@ -187,4 +266,40 @@ describe('DynamicCRUDPage + document_forms', () => {
187
266
  await new Promise((r) => setTimeout(r, 50))
188
267
  expect(screen.queryByRole('radiogroup')).toBeNull()
189
268
  })
269
+
270
+ // Retest r3 (05/10): «Documentos fiscales → Facturas» (nav filtrado por el
271
+ // type_field) ofrecía Crear y abría el REP, el único tipo declarado.
272
+ it('vista filtrada a un tipo sin formulario: sin botón Crear', async () => {
273
+ mount(meta({ document_forms: forms }), { type: 'P' })
274
+ await screen.findByText(/Nota/)
275
+ await new Promise((r) => setTimeout(r, 50))
276
+ expect(screen.queryByText(/New Nota/)).toBeNull()
277
+ })
278
+
279
+ it('vista filtrada a un tipo con formulario: Crear abre ese tipo directo', async () => {
280
+ mount(meta({ document_forms: forms }), { type: 'eq:E' })
281
+ fireEvent.click((await screen.findByText(/New Nota/)).closest('button')!)
282
+ expect(await screen.findByText('Factura origen')).toBeTruthy()
283
+ expect(screen.queryByRole('radiogroup')).toBeNull()
284
+ })
285
+ })
286
+
287
+ describe('scopeDocumentFormsToFilter', () => {
288
+ it('sin filtro sobre el type_field no cambia nada', () => {
289
+ expect(scopeDocumentFormsToFilter(forms, undefined)).toEqual({ forms, hideCreate: false })
290
+ expect(scopeDocumentFormsToFilter(forms, { status: 'draft' })).toEqual({ forms, hideCreate: false })
291
+ expect(scopeDocumentFormsToFilter(undefined, { type: 'I' })).toEqual({ forms: undefined, hideCreate: false })
292
+ expect(scopeDocumentFormsToFilter({ ...forms, type_field: undefined }, { type: 'X' }).hideCreate).toBe(false)
293
+ })
294
+
295
+ it('acota al tipo del filtro (valor o key, sin importar mayúsculas ni eq:)', () => {
296
+ expect(scopeDocumentFormsToFilter(forms, { type: 'e' }).forms?.types.map((x) => x.key)).toEqual(['credit_note'])
297
+ expect(scopeDocumentFormsToFilter(forms, { type: 'eq:I' }).forms?.types.map((x) => x.key)).toEqual(['invoice'])
298
+ const noValue: DocumentFormsManifest = { type_field: 'document_type', types: [{ key: 'payment_complement', label: 'REP', fields: [] }] }
299
+ expect(scopeDocumentFormsToFilter(noValue, { document_type: 'payment_complement' }).forms).toBe(noValue)
300
+ })
301
+
302
+ it('filtro a un tipo sin formulario: oculta Crear', () => {
303
+ expect(scopeDocumentFormsToFilter(forms, { type: 'P' })).toEqual({ forms: undefined, hideCreate: true })
304
+ })
190
305
  })
@@ -0,0 +1,155 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Editar un registro con un select que YA tiene valor y guardar sin tocarlo debe
4
+ // conservar ese valor en el payload. Antes los `values` del DynamicForm se
5
+ // sembraban en un efecto (primer render con '') y el Select de Radix emitía
6
+ // `onValueChange('')`, que pisaba el valor real y viajaba en el PATCH (p.ej. un
7
+ // renglón «Refacción» quedaba en «-»). Cubre también: `readonly` de columna en
8
+ // `deriveRelationFormFields`, selects vacíos no tocados fuera del PATCH, y el
9
+ // modal de renglón con cuerpo con scroll y footer fijo.
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
12
+
13
+ vi.mock('react-i18next', () => ({
14
+ useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
15
+ }))
16
+
17
+ import { DynamicForm, guardEmptySelect } from '../dynamic-form'
18
+ import { DynamicRelation } from '../dynamic-relation'
19
+ import { deriveRelationFormFields } from '../dynamic-relation-helpers'
20
+ import { ApiProvider, type ApiClient } from '../api-context'
21
+ import type { ActionFieldDef } from '../types'
22
+
23
+ afterEach(cleanup)
24
+
25
+ const kindField: ActionFieldDef = {
26
+ key: 'kind',
27
+ label: 'Tipo',
28
+ type: 'select',
29
+ options: [
30
+ { value: 'service', label: 'Servicio' },
31
+ { value: 'part', label: 'Refacción' },
32
+ ],
33
+ }
34
+
35
+ describe('DynamicForm — edición con select', () => {
36
+ it('guardar sin tocar el select conserva su valor en el payload', async () => {
37
+ const onSubmit = vi.fn()
38
+ render(
39
+ <DynamicForm
40
+ fields={[{ key: 'description', label: 'Descripción', type: 'text' }, kindField]}
41
+ initialValues={{ id: 'r1', description: 'Balata', kind: 'part' }}
42
+ onSubmit={onSubmit}
43
+ />,
44
+ )
45
+ await act(async () => {
46
+ screen.getByText('Guardar').click()
47
+ })
48
+ expect(onSubmit).toHaveBeenCalledTimes(1)
49
+ expect(onSubmit.mock.calls[0][0]).toMatchObject({ description: 'Balata', kind: 'part' })
50
+ })
51
+
52
+ it('en edición no manda un select vacío que el usuario no tocó', async () => {
53
+ const onSubmit = vi.fn()
54
+ render(
55
+ <DynamicForm
56
+ fields={[{ key: 'description', label: 'Descripción', type: 'text' }, kindField]}
57
+ initialValues={{ id: 'r1', description: 'Balata' }}
58
+ onSubmit={onSubmit}
59
+ />,
60
+ )
61
+ await act(async () => {
62
+ screen.getByText('Guardar').click()
63
+ })
64
+ expect(onSubmit).toHaveBeenCalledTimes(1)
65
+ expect(onSubmit.mock.calls[0][0]).not.toHaveProperty('kind')
66
+ })
67
+ })
68
+
69
+ describe('guardEmptySelect', () => {
70
+ it("ignora '' cuando ya hay valor y '' no es opción", () => {
71
+ const onChange = vi.fn()
72
+ guardEmptySelect('part', onChange, ['service', 'part'])('')
73
+ expect(onChange).not.toHaveBeenCalled()
74
+ })
75
+ it("deja pasar '' si no había valor o si '' es opción válida, y cualquier otro valor", () => {
76
+ const onChange = vi.fn()
77
+ guardEmptySelect('', onChange, ['service', 'part'])('')
78
+ guardEmptySelect('part', onChange, ['', 'part'])('')
79
+ guardEmptySelect('part', onChange, ['service', 'part'])('service')
80
+ expect(onChange.mock.calls).toEqual([[''], [''], ['service']])
81
+ })
82
+ })
83
+
84
+ const META = {
85
+ name: 'work_order_item',
86
+ columns: [
87
+ { key: 'id', label: 'ID', type: 'text', sortable: true, filterable: false, hidden: true },
88
+ { key: 'work_order_id', label: 'OT', type: 'text', sortable: false, filterable: false },
89
+ { key: 'description', label: 'Descripción', type: 'text', sortable: true, filterable: true },
90
+ {
91
+ key: 'kind',
92
+ label: 'Tipo',
93
+ type: 'select',
94
+ sortable: true,
95
+ filterable: true,
96
+ options: [
97
+ { value: 'service', label: 'Servicio' },
98
+ { value: 'part', label: 'Refacción' },
99
+ ],
100
+ },
101
+ { key: 'line_total', label: 'Importe', type: 'number', sortable: false, filterable: false, readonly: true },
102
+ ],
103
+ actions: [],
104
+ hasActions: false,
105
+ enableCRUDActions: false,
106
+ }
107
+
108
+ const DATA = [{ id: 'woi_1', work_order_id: 'wo_1', description: 'Balata', kind: 'part', line_total: 450 }]
109
+
110
+ function mockApi(): ApiClient {
111
+ return {
112
+ get: vi.fn((url: string) => {
113
+ if (url.startsWith('/metadata/table/')) {
114
+ return Promise.resolve({ data: { success: true, data: META } })
115
+ }
116
+ return Promise.resolve({ data: { success: true, data: DATA } })
117
+ }),
118
+ post: vi.fn(() => Promise.resolve({ data: { success: true, data: {} } })),
119
+ put: vi.fn(() => Promise.resolve({ data: { success: true, data: {} } })),
120
+ delete: vi.fn(() => Promise.resolve({ data: { success: true, data: {} } })),
121
+ }
122
+ }
123
+
124
+ describe('DynamicRelation — editar renglón con select', () => {
125
+ it('deriveRelationFormFields copia readonly de la columna', () => {
126
+ const fields = deriveRelationFormFields(META as any, 'work_order_id')
127
+ expect(fields.find((f) => f.key === 'line_total')?.readonly).toBe(true)
128
+ expect(fields.find((f) => f.key === 'kind')?.readonly).toBeUndefined()
129
+ })
130
+
131
+ it('el PATCH conserva el tipo y no manda columnas readonly; el modal hace scroll con footer fijo', async () => {
132
+ const api = mockApi()
133
+ render(
134
+ <ApiProvider client={api}>
135
+ <DynamicRelation kind="one_to_many" model="work_order_item" foreignKey="work_order_id" parentId="wo_1" />
136
+ </ApiProvider>,
137
+ )
138
+ await waitFor(() => expect(screen.getByText('Balata')).toBeTruthy())
139
+ await act(async () => {
140
+ screen.getByLabelText('Editar').click()
141
+ })
142
+ const dialog = await screen.findByRole('dialog')
143
+ expect(dialog.className).toContain('max-h-[90dvh]')
144
+ expect(dialog.querySelector('.overflow-y-auto')).toBeTruthy()
145
+
146
+ await act(async () => {
147
+ screen.getByText('Guardar').click()
148
+ })
149
+ await waitFor(() => expect(api.put).toHaveBeenCalledTimes(1))
150
+ const [url, payload] = (api.put as any).mock.calls[0]
151
+ expect(url).toContain('woi_1')
152
+ expect(payload).toMatchObject({ description: 'Balata', kind: 'part' })
153
+ expect(payload).not.toHaveProperty('line_total')
154
+ })
155
+ })
@@ -293,11 +293,32 @@ describe('withGroupBy (one model, several boards)', () => {
293
293
  ...over,
294
294
  }) as any
295
295
 
296
- it('is the same object when there is no override or it matches the served group_by', () => {
297
- const m = boardMeta()
296
+ it('is the same object when the served group_by is already the stage column', () => {
297
+ const m = boardMeta({ group_by: 'stage' })
298
298
  expect(withGroupBy(m, undefined)).toBe(m)
299
- expect(withGroupBy(m, 'assigned_mechanic_id')).toBe(m)
299
+ expect(withGroupBy(m, 'stage')).toBe(m)
300
300
  expect(withGroupBy(null, 'stage')).toBeNull()
301
+ const flat = boardMeta({ stages: undefined, transitions: undefined, smart_lanes: undefined })
302
+ expect(withGroupBy(flat, 'assigned_mechanic_id')).toBe(flat)
303
+ expect(withGroupBy(flat, undefined)).toBe(flat)
304
+ })
305
+ // Pitsline r3 (b): WorkOrder serves group_by=service_bay_id (the last kanban
306
+ // nav entry) with the stage lanes. Without ?group_by= the board grouped the
307
+ // stage lanes by the bay: every card in «Sin etapa», each lane count sent
308
+ // f_service_bay_id=diagnosis and a drop PUT service_bay_id='diagnosis' (422).
309
+ it('groups a board without ?group_by= by the stage column, not the served one', () => {
310
+ const out = withGroupBy(boardMeta(), undefined)!
311
+ expect(out.group_by).toBe('stage')
312
+ expect(out.stages).toHaveLength(1)
313
+ expect(out.transitions).toHaveLength(1)
314
+ })
315
+ it('drops the stage machine on the board of the served (non-stage) column', () => {
316
+ const out = withGroupBy(boardMeta(), 'assigned_mechanic_id')!
317
+ expect(out.group_by).toBe('assigned_mechanic_id')
318
+ expect(out.stages).toBeUndefined()
319
+ expect(out.transitions).toBeUndefined()
320
+ expect(out.smart_lanes).toBeUndefined()
321
+ expect(refLaneSource(out)).toEqual({ ref: 'users' })
301
322
  })
302
323
  it('ignores a column the model does not have', () => {
303
324
  const m = boardMeta()
@@ -0,0 +1,88 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Retest Pitsline 05/10: the customer and product forms printed the fields that
4
+ // fiscal_mexico adds by extension as `FISCAL_MEXICO.EXT.CUSTOMER.RFC_RECEPTOR`.
5
+ // The server served the label as the raw catalog key and the dialog rendered it
6
+ // verbatim (uppercased by CSS). The dialog now resolves such a key with the
7
+ // client catalog, and humanizes it when the catalog does not have it either.
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { cleanup, render, waitFor } from '@testing-library/react'
10
+ import { I18nextProvider } from 'react-i18next'
11
+ import i18next from 'i18next'
12
+
13
+ import { DynamicRecordDialog } from '../dialogs/dynamic-record'
14
+ import { ApiProvider } from '../api-context'
15
+ import { localizeFieldLabel } from '../dynamic-columns-helpers'
16
+
17
+ afterEach(cleanup)
18
+
19
+ function makeI18n() {
20
+ const inst = i18next.createInstance()
21
+ void inst.init({
22
+ lng: 'es',
23
+ fallbackLng: 'es',
24
+ react: { useSuspense: false },
25
+ resources: {
26
+ es: {
27
+ translation: {
28
+ fiscal_mexico: { ext: { customer: { rfc_receptor: 'RFC' } } },
29
+ },
30
+ },
31
+ },
32
+ })
33
+ return inst
34
+ }
35
+
36
+ const api = {
37
+ get: vi.fn(async () => ({ data: { data: {} } })),
38
+ post: async () => ({ data: { success: true } }),
39
+ put: async () => ({ data: { success: true } }),
40
+ delete: async () => ({ data: { success: true } }),
41
+ } as never
42
+
43
+ describe('DynamicRecordDialog — extension field labels', () => {
44
+ it('never prints a raw catalog key as the field label', async () => {
45
+ render(
46
+ <I18nextProvider i18n={makeI18n()}>
47
+ <ApiProvider client={api}>
48
+ <DynamicRecordDialog
49
+ open
50
+ onOpenChange={() => {}}
51
+ mode="create"
52
+ model="customers"
53
+ schema={{
54
+ title: 'Nuevo cliente',
55
+ fields: [
56
+ { key: 'name', label: 'Nombre', type: 'text' },
57
+ { key: 'fiscal_data.rfc_receptor', label: 'fiscal_mexico.ext.customer.rfc_receptor', type: 'text' },
58
+ { key: 'fiscal_data.codigo_postal', label: 'fiscal_mexico.ext.customer.codigo_postal', type: 'text' },
59
+ ],
60
+ } as never}
61
+ />
62
+ </ApiProvider>
63
+ </I18nextProvider>,
64
+ )
65
+
66
+ const label = (key: string) =>
67
+ document.querySelector(`[data-aby-field="${key}"] label`)?.textContent ?? ''
68
+ await waitFor(() => expect(label('fiscal_data.rfc_receptor')).toBe('RFC'))
69
+ expect(label('fiscal_data.codigo_postal')).toBe('Codigo Postal')
70
+ expect(label('name')).toBe('Nombre')
71
+ expect(document.body.textContent).not.toContain('fiscal_mexico.ext')
72
+ })
73
+ })
74
+
75
+ describe('localizeFieldLabel', () => {
76
+ it('leaves human labels alone, even single words or acronyms', () => {
77
+ const t = vi.fn((k: string) => `T:${k}`)
78
+ expect(localizeFieldLabel('Nombre', t)).toBe('Nombre')
79
+ expect(localizeFieldLabel('RFC', t)).toBe('RFC')
80
+ expect(localizeFieldLabel('Código postal', t)).toBe('Código postal')
81
+ expect(t).not.toHaveBeenCalled()
82
+ })
83
+
84
+ it('resolves catalog keys and humanizes the ones the catalog lacks', () => {
85
+ expect(localizeFieldLabel('a.b', (k: string, o?: { defaultValue?: string }) => (k === 'a.b' ? 'Hola' : o?.defaultValue ?? k))).toBe('Hola')
86
+ expect(localizeFieldLabel('fiscal_mexico.ext.product.mx_clave_unidad', (k: string) => k)).toBe('Mx Clave Unidad')
87
+ })
88
+ })
@@ -0,0 +1,50 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // InstalledAddonsProvider: sin provider = desconocido (undefined), con provider
4
+ // responde por clave de addon y por capacidad.
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+ import { cleanup, render, screen } from '@testing-library/react'
7
+ import {
8
+ InstalledAddonsProvider,
9
+ useAddonInstalled,
10
+ useCapabilityProvided,
11
+ useInstalledAddons,
12
+ } from '../installed-addons-context'
13
+
14
+ afterEach(() => cleanup())
15
+
16
+ function Probe() {
17
+ const fiscal = useAddonInstalled('fiscal_mexico')
18
+ const issuer = useCapabilityProvided('fiscal.invoice_issuer')
19
+ const all = useInstalledAddons()
20
+ return (
21
+ <span data-testid="probe">
22
+ {String(fiscal)}|{String(issuer)}|{all ? [...all.addons].join(',') : 'null'}
23
+ </span>
24
+ )
25
+ }
26
+
27
+ describe('InstalledAddonsProvider', () => {
28
+ it('sin provider devuelve undefined/null (nunca asume ausente)', () => {
29
+ render(<Probe />)
30
+ expect(screen.getByTestId('probe').textContent).toBe('undefined|undefined|null')
31
+ })
32
+
33
+ it('responde por addon y por capacidad', () => {
34
+ render(
35
+ <InstalledAddonsProvider addons={['customers', 'fiscal_mexico']} capabilities={['fiscal.invoice_issuer']}>
36
+ <Probe />
37
+ </InstalledAddonsProvider>,
38
+ )
39
+ expect(screen.getByTestId('probe').textContent).toBe('true|true|customers,fiscal_mexico')
40
+ })
41
+
42
+ it('addon no instalado = false', () => {
43
+ render(
44
+ <InstalledAddonsProvider addons={['customers']}>
45
+ <Probe />
46
+ </InstalledAddonsProvider>,
47
+ )
48
+ expect(screen.getByTestId('probe').textContent).toBe('false|false|customers')
49
+ })
50
+ })
@@ -25,6 +25,34 @@ const journalField = (overrides: Partial<ActionFieldDef> = {}): ActionFieldDef =
25
25
  })
26
26
 
27
27
  describe('computeLineItemTotals', () => {
28
+ // Pitsline r3 (g): the work order lines carry qty + a `total` price column
29
+ // and no amount column. qty=2 × 750 footed 750; the saved order was 1500.
30
+ it('suma qty × precio cuando la columna total es el precio unitario', () => {
31
+ const field = {
32
+ key: 'lines',
33
+ type: 'array',
34
+ itemFields: [
35
+ { key: 'description', type: 'string' },
36
+ { key: 'qty', type: 'number' },
37
+ { key: 'price', type: 'number', total: true },
38
+ ],
39
+ } as any
40
+ const rows = [{ qty: '2', price: '750' }, { qty: 1, price: 99.9 }]
41
+ expect(computeLineItemTotals(field, rows)).toEqual({ price: 1599.9 })
42
+ })
43
+ it('con columna de importe, el precio unitario se suma tal cual', () => {
44
+ const field = {
45
+ key: 'lines',
46
+ type: 'array',
47
+ itemFields: [
48
+ { key: 'qty', type: 'number' },
49
+ { key: 'unit_price', type: 'number', total: true },
50
+ { key: 'subtotal', type: 'number', total: true },
51
+ ],
52
+ } as any
53
+ const rows = [{ qty: 2, unit_price: 10, subtotal: 20 }]
54
+ expect(computeLineItemTotals(field, rows)).toEqual({ unit_price: 10, subtotal: 20 })
55
+ })
28
56
  it('suma solo las columnas marcadas con total', () => {
29
57
  const rows = [
30
58
  { account_id: 'a', debit: '100', credit: '' },