@asteby/metacore-runtime-react 49.1.0 → 49.2.1

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 (39) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/action-modal-dispatcher.d.ts +1 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +34 -5
  5. package/dist/business/document-editor-model.d.ts +27 -1
  6. package/dist/business/document-editor-model.d.ts.map +1 -1
  7. package/dist/business/document-editor-model.js +104 -7
  8. package/dist/business/document-editor-panels.d.ts +18 -0
  9. package/dist/business/document-editor-panels.d.ts.map +1 -1
  10. package/dist/business/document-editor-panels.js +9 -0
  11. package/dist/business/document-editor.d.ts +7 -1
  12. package/dist/business/document-editor.d.ts.map +1 -1
  13. package/dist/business/document-editor.js +106 -41
  14. package/dist/document-form-dialog.d.ts.map +1 -1
  15. package/dist/document-form-dialog.js +8 -3
  16. package/dist/dynamic-row-actions.d.ts +6 -0
  17. package/dist/dynamic-row-actions.d.ts.map +1 -1
  18. package/dist/dynamic-row-actions.js +9 -4
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/record-mutation-events.d.ts +12 -0
  23. package/dist/record-mutation-events.d.ts.map +1 -1
  24. package/dist/record-mutation-events.js +18 -0
  25. package/package.json +1 -1
  26. package/src/__tests__/document-editor-model.test.ts +27 -0
  27. package/src/__tests__/document-editor.test.tsx +60 -0
  28. package/src/__tests__/invoice-document-editor.test.tsx +267 -0
  29. package/src/__tests__/r5-self-label-settled.test.ts +57 -0
  30. package/src/__tests__/r6-editor-source-seeds.test.tsx +137 -0
  31. package/src/__tests__/row-action-link-navigation.test.ts +10 -6
  32. package/src/action-modal-dispatcher.tsx +34 -5
  33. package/src/business/document-editor-model.ts +118 -7
  34. package/src/business/document-editor-panels.tsx +49 -0
  35. package/src/business/document-editor.tsx +288 -177
  36. package/src/document-form-dialog.tsx +13 -4
  37. package/src/dynamic-row-actions.tsx +12 -4
  38. package/src/index.ts +2 -0
  39. package/src/record-mutation-events.ts +23 -0
@@ -0,0 +1,267 @@
1
+ // @vitest-environment happy-dom
2
+ // Facturas → Crear (P0 UX Acxel, 2026-10-06): el tipo factura con
3
+ // `layout: "editor"` abre el DocumentEditor a pantalla completa, no el wizard
4
+ // «Datos → Siguiente». A la vista solo cliente (con su tarjeta fiscal), método
5
+ // y forma de pago, renglones y totales; lo demás en «Opciones fiscales»
6
+ // plegada, una sola acción primaria y la vista previa plegada. «Cargar desde»
7
+ // OT de taller y cotización precarga lo que queda por facturar.
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
10
+
11
+ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
12
+ vi.mock('react-i18next', () => {
13
+ const i18n = { language: 'es' }
14
+ const dict: Record<string, string> = { 'customers.field.tax_id': 'RFC' }
15
+ const t = (k: string, o?: Record<string, any>) => {
16
+ let s: string = dict[k] ?? o?.defaultValue ?? k
17
+ for (const [key, v] of Object.entries(o ?? {})) s = s.replace(`{{${key}}}`, String(v))
18
+ return s
19
+ }
20
+ const value = { t, i18n }
21
+ return { useTranslation: () => value }
22
+ })
23
+
24
+ import { DocumentFormDialog } from '../document-form-dialog'
25
+ import { ApiProvider, type ApiClient } from '../api-context'
26
+ import { TaxRateContext } from '../org-runtime-context'
27
+ import { linesFromSource, splitEditorFields } from '../business/document-editor-model'
28
+ import type { DocumentFormsManifest, DocumentFormType } from '../types'
29
+
30
+ afterEach(cleanup)
31
+
32
+ // Espejo del tipo `invoice` de customers.Invoice (addons, manifest.json).
33
+ const invoice: DocumentFormType = {
34
+ key: 'invoice',
35
+ label: 'Factura',
36
+ value: 'I',
37
+ layout: 'editor',
38
+ party: {
39
+ field: 'customer_id',
40
+ model: 'customers.Customer',
41
+ summary: ['tax_id', 'fiscal_data.regimen_receptor', 'fiscal_data.codigo_postal', 'fiscal_data.uso_cfdi'],
42
+ credit: { limit: 'credit_limit', balance: 'balance_due', overdue: 'overdue_amount', hold_reason: 'credit_hold_reason' },
43
+ },
44
+ fields: [
45
+ { key: 'customer_id', label: 'Cliente', type: 'text', required: true },
46
+ { key: 'invoice_date', label: 'Fecha de emisión', type: 'date', required: true, defaultValue: '$today' },
47
+ { key: 'due_date', label: 'Vencimiento', type: 'date' },
48
+ { key: 'currency_code', label: 'Moneda', type: 'select', defaultValue: 'MXN', options: [{ value: 'MXN', label: 'Peso mexicano · MXN' }, { value: 'USD', label: 'Dólar · USD' }] },
49
+ { key: 'fiscal_data.metodo_pago', label: 'Método de pago', type: 'select', required: true, defaultValue: 'PUE', options: [{ value: 'PUE', label: 'Pago en una sola exhibición · PUE' }, { value: 'PPD', label: 'Pago en parcialidades o diferido · PPD' }] },
50
+ { key: 'fiscal_data.forma_pago', label: 'Forma de pago', type: 'select', required: true, defaultValue: '01', options: [{ value: '01', label: 'Efectivo · 01' }, { value: '03', label: 'Transferencia electrónica · 03' }] },
51
+ { key: 'notes', label: 'Notas', type: 'textarea' },
52
+ ],
53
+ lines: { columns: ['discount', 'tax'], discount_mode: 'amount', title: 'Renglones' },
54
+ sources: [
55
+ { key: 'sales_order', label: 'Venta', model: 'customers.SalesOrder', lines: 'items', map: { description: 'product_name' }, header: { customer_id: 'customer_id' }, link_field: 'sales_order_id', line_link_field: 'sales_order_item_id', exclude_states: ['cancelled', 'void'] },
56
+ { key: 'work_order', label: 'Orden de trabajo', model: 'workshop.WorkOrder', lines: 'items', header: { customer_id: 'customer_id' }, link_field: 'work_order_id', line_link_field: 'work_order_item_id', exclude_states: ['cancelled', 'void'] },
57
+ { key: 'quote', label: 'Cotización', model: 'quotes.Quote', lines: 'items', map: { discount: 'discount_amount' }, header: { customer_id: 'customer_id', currency_code: 'currency_code' }, link_field: 'quote_id', line_link_field: 'quote_item_id', exclude_states: ['cancelled', 'void'] },
58
+ ],
59
+ submit_label: 'Crear factura',
60
+ }
61
+ const forms: DocumentFormsManifest = { lines_field: 'items', types: [invoice] }
62
+
63
+ const customer = {
64
+ id: 'c-1',
65
+ name: 'Transportes del Bajío',
66
+ tax_id: 'TBA850312KJ9',
67
+ credit_limit: 100000,
68
+ balance_due: 15750,
69
+ fiscal_data: { regimen_receptor: '601', codigo_postal: '37000', uso_cfdi: 'G03' },
70
+ }
71
+
72
+ // Lo pendiente lo sirve el runtime (kernel source-lines): cantidad − lo ya facturado.
73
+ const workOrderLines = {
74
+ data: [
75
+ { id: 'woi-1', source_line_id: 'woi-1', kind: 'labor', description: 'Alineación eje direccional', quantity: 1, remaining_quantity: 1, unit_price: 650 },
76
+ { id: 'woi-2', source_line_id: 'woi-2', kind: 'part', product_id: 'p-valv', description: 'Válvula de alta presión', quantity: 6, remaining_quantity: 2, unit_price: 95 },
77
+ { id: 'woi-3', source_line_id: 'woi-3', kind: 'labor', description: 'Montaje y balanceo', quantity: 6, remaining_quantity: 0, unit_price: 180 },
78
+ ],
79
+ }
80
+ const quoteLines = {
81
+ data: [
82
+ { id: 'qi-1', source_line_id: 'qi-1', product_id: 'p-llanta', description: 'Llanta 295/80R22.5', quantity: 6, remaining_quantity: 4, unit_price: 5890, discount_amount: 1200, tax_rate: 0.16, tax_amount: 5462.4, subtotal: 34140 },
83
+ ],
84
+ }
85
+
86
+ function makeApi(sourceLines: Record<string, unknown> = {}, post = vi.fn().mockResolvedValue({ data: { success: true, data: { id: 'inv-1' } } })) {
87
+ const get = vi.fn(async (url: string, cfg?: { params?: Record<string, any> }) => {
88
+ if (url === '/metadata/modal/customers.Customer') {
89
+ return { data: { data: { fields: [{ key: 'tax_id', label: 'customers.field.tax_id' }, { key: 'fiscal_data.regimen_receptor', label: 'Régimen fiscal' }, { key: 'fiscal_data.codigo_postal', label: 'CP fiscal' }, { key: 'fiscal_data.uso_cfdi', label: 'Uso CFDI' }] } } }
90
+ }
91
+ if (url === '/data/customers.Customer') return { data: { data: [customer] } }
92
+ if (url.endsWith('/source-lines')) return { data: sourceLines[cfg?.params?.source] ?? { data: [] } }
93
+ if (url === '/data/workshop.WorkOrder') return { data: { data: [{ id: 'wo-1', customer_id: 'c-1' }] } }
94
+ if (url === '/data/quotes.Quote') return { data: { data: [{ id: 'q-1', customer_id: 'c-1', currency_code: 'MXN' }] } }
95
+ return { data: { data: [] } }
96
+ })
97
+ const client = { get, post, put: vi.fn().mockResolvedValue({ data: { success: true, data: { id: 'inv-1' } } }), delete: vi.fn() }
98
+ return { client: client as unknown as ApiClient, get, post }
99
+ }
100
+
101
+ function renderInvoice(api: ReturnType<typeof makeApi>, initialSource?: { key: string; id: string }) {
102
+ return render(
103
+ <ApiProvider client={api.client}>
104
+ <TaxRateContext.Provider value={0.16}>
105
+ <DocumentFormDialog
106
+ open
107
+ onOpenChange={() => {}}
108
+ model="customers.Invoice"
109
+ forms={forms}
110
+ initialType={initialSource ? 'invoice' : undefined}
111
+ initialSource={initialSource}
112
+ currency="MXN"
113
+ />
114
+ </TaxRateContext.Provider>
115
+ </ApiProvider>,
116
+ )
117
+ }
118
+
119
+ const slot = (name: string) => document.querySelector(`[data-slot="${name}"]`) as HTMLElement
120
+
121
+ describe('Factura con layout editor — pantalla única', () => {
122
+ it('abre el DocumentEditor a pantalla completa, no el wizard', () => {
123
+ renderInvoice(makeApi())
124
+ const dialog = slot('document-editor-dialog')
125
+ expect(dialog.getAttribute('data-fullscreen')).toBe('true')
126
+ expect(dialog.style.height).toBe('calc(100dvh - 2rem)')
127
+ expect(slot('document-editor').getAttribute('data-layout')).toBe('fullscreen')
128
+ expect(screen.queryByRole('button', { name: 'Siguiente' })).toBeNull()
129
+ expect(document.querySelector('[data-slot="document-form-dialog"]')).toBeNull()
130
+ })
131
+
132
+ it('secciones: lo esencial a la vista, «Opciones fiscales» plegada, totales y vista previa al lado', () => {
133
+ renderInvoice(makeApi())
134
+ const main = slot('editor-main')
135
+ const essentials = within(slot('editor-essentials'))
136
+ for (const label of ['Cliente', 'Fecha de emisión', 'Vencimiento', 'Método de pago', 'Forma de pago']) {
137
+ expect(essentials.getByText(label)).toBeTruthy()
138
+ }
139
+ // Etiqueta clara con el código SAT como dato secundario.
140
+ expect(screen.getAllByText('Pago en una sola exhibición · PUE').length).toBeGreaterThan(0)
141
+ expect(within(main).getByText('Cargar desde…')).toBeTruthy()
142
+ expect(within(main).getByText('Renglones')).toBeTruthy()
143
+
144
+ const toggle = screen.getByRole('button', { name: /Opciones fiscales/ })
145
+ expect(toggle.getAttribute('aria-expanded')).toBe('false')
146
+ const panel = document.getElementById(toggle.getAttribute('aria-controls')!)!
147
+ expect(panel.hidden).toBe(true)
148
+ expect(within(panel).getByText('Moneda')).toBeTruthy()
149
+ expect(essentials.queryByText('Moneda')).toBeNull()
150
+
151
+ const aside = within(slot('editor-aside'))
152
+ const totals = slot('editor-aside').querySelector(':scope > div > [data-slot="totals"]') as HTMLElement
153
+ expect(within(totals).getByText('Total')).toBeTruthy()
154
+ const preview = aside.getByRole('button', { name: /Vista previa/ })
155
+ expect(preview.getAttribute('aria-expanded')).toBe('false')
156
+ })
157
+
158
+ it('una sola acción primaria: «Crear factura»', () => {
159
+ renderInvoice(makeApi())
160
+ const primaries = document.querySelectorAll('[data-slot="document-editor"] [data-primary="true"]')
161
+ expect(primaries.length).toBe(1)
162
+ expect(primaries[0].textContent).toBe('Crear factura')
163
+ })
164
+
165
+ it('«Cargar desde» ofrece venta, OT y cotización como opciones', () => {
166
+ renderInvoice(makeApi())
167
+ const group = screen.getByRole('radiogroup', { name: 'Tipo de documento origen' })
168
+ expect(within(group).getAllByRole('radio').map((r) => r.textContent)).toEqual(['Venta', 'Orden de trabajo', 'Cotización'])
169
+ fireEvent.click(within(group).getByRole('radio', { name: 'Orden de trabajo' }))
170
+ expect(within(group).getByRole('radio', { name: 'Orden de trabajo' }).getAttribute('aria-checked')).toBe('true')
171
+ expect(document.querySelector('[data-source="work_order"]')).toBeTruthy()
172
+ })
173
+ })
174
+
175
+ describe('Factura — «Cargar desde» OT y cotización con lo restante', () => {
176
+ it('OT: cliente, renglones con lo pendiente (omite lo ya facturado), IVA de la org y vínculos', async () => {
177
+ const api = makeApi({ work_order: workOrderLines })
178
+ renderInvoice(api, { key: 'work_order', id: 'wo-1' })
179
+ await screen.findByDisplayValue('Válvula de alta presión')
180
+ expect(screen.getByDisplayValue('Alineación eje direccional')).toBeTruthy()
181
+ expect(screen.queryByDisplayValue('Montaje y balanceo')).toBeNull()
182
+ expect(api.get).toHaveBeenCalledWith('/dynamic/customers.Invoice/source-lines', { params: { source: 'work_order', id: 'wo-1' } })
183
+ expect(await screen.findByText('2 renglones con lo pendiente · 1 ya cubiertos se omitieron')).toBeTruthy()
184
+
185
+ // La tarjeta del cliente: datos fiscales con etiquetas del modelo.
186
+ await waitFor(() => expect(slot('party-card')).toBeTruthy())
187
+ const card = slot('party-card')
188
+ expect(within(card).getByText('Transportes del Bajío')).toBeTruthy()
189
+ expect(within(card).getByText('RFC')).toBeTruthy()
190
+ expect(within(card).getByText('TBA850312KJ9')).toBeTruthy()
191
+ expect(within(card).getByText('Uso CFDI')).toBeTruthy()
192
+ expect(within(card).getByText('G03')).toBeTruthy()
193
+
194
+ fireEvent.click(screen.getByRole('button', { name: 'Crear factura' }))
195
+ await waitFor(() => expect(api.post).toHaveBeenCalled())
196
+ const [url, body] = api.post.mock.calls[0] as [string, any]
197
+ expect(url).toBe('/dynamic/customers.Invoice')
198
+ expect(body.customer_id).toBe('c-1')
199
+ expect(body.work_order_id).toBe('wo-1')
200
+ expect(body['fiscal_data.metodo_pago']).toBe('PUE')
201
+ expect(body['fiscal_data.forma_pago']).toBe('01')
202
+ expect(body.items).toEqual([
203
+ expect.objectContaining({ description: 'Alineación eje direccional', quantity: 1, unit_price: 650, tax_rate: 0.16, work_order_item_id: 'woi-1' }),
204
+ expect.objectContaining({ product_id: 'p-valv', quantity: 2, unit_price: 95, tax_rate: 0.16, work_order_item_id: 'woi-2' }),
205
+ ])
206
+ })
207
+
208
+ it('cotización: lo pendiente, su IVA y descuento, moneda y vínculo', async () => {
209
+ const api = makeApi({ quote: quoteLines })
210
+ renderInvoice(api, { key: 'quote', id: 'q-1' })
211
+ await screen.findByDisplayValue('Llanta 295/80R22.5')
212
+ fireEvent.click(screen.getByRole('button', { name: 'Crear factura' }))
213
+ await waitFor(() => expect(api.post).toHaveBeenCalled())
214
+ const [, body] = api.post.mock.calls[0] as [string, any]
215
+ expect(body.quote_id).toBe('q-1')
216
+ expect(body.currency_code).toBe('MXN')
217
+ expect(body.items).toEqual([
218
+ expect.objectContaining({ product_id: 'p-llanta', quantity: 4, unit_price: 5890, discount: 1200, tax_rate: 0.16, quote_item_id: 'qi-1' }),
219
+ ])
220
+ expect(body.items[0]).not.toHaveProperty('work_order_item_id')
221
+ })
222
+
223
+ it('exceso: la cantidad no pasa de lo pendiente y el rechazo del servidor se muestra inline', async () => {
224
+ const post = vi.fn().mockRejectedValue({
225
+ response: {
226
+ status: 422,
227
+ data: {
228
+ success: false,
229
+ message: 'validation failed',
230
+ errors: { 'items.1.quantity': [{ code: 'exceeds_remaining', message: 'Renglón 2: la cantidad 2 excede lo pendiente de «Orden de trabajo» (1). Ajusta la cantidad; lo demás ya está registrado en otro documento.' }] },
231
+ },
232
+ },
233
+ })
234
+ const api = makeApi({ work_order: workOrderLines }, post)
235
+ renderInvoice(api, { key: 'work_order', id: 'wo-1' })
236
+ await screen.findByDisplayValue('Válvula de alta presión')
237
+ // El editor topa la cantidad en lo pendiente (2 de 6).
238
+ const qty = screen.getAllByLabelText('Cant.').filter((el) => el.tagName === 'INPUT')[1] as HTMLInputElement
239
+ fireEvent.change(qty, { target: { value: '5' } })
240
+ expect(qty.value).toBe('2')
241
+ // Si otro documento consumió lo pendiente mientras tanto, el servidor rechaza.
242
+ fireEvent.click(screen.getByRole('button', { name: 'Crear factura' }))
243
+ await waitFor(() => expect(post).toHaveBeenCalled())
244
+ expect(await screen.findByText(/excede lo pendiente de «Orden de trabajo»/)).toBeTruthy()
245
+ expect(slot('document-editor-dialog')).toBeTruthy()
246
+ })
247
+ })
248
+
249
+ describe('reglas puras del editor de factura', () => {
250
+ it('lo obligatorio que el tipo declara queda a la vista aunque sea fiscal_data.*; las extensiones del metadata se pliegan', () => {
251
+ const g = splitEditorFields(invoice.fields, {
252
+ partyField: 'customer_id',
253
+ extensionFields: [{ key: 'fiscal_data.uso_cfdi', label: 'Uso CFDI', type: 'select', required: true, options: [{ value: 'G03', label: 'G03' }] }],
254
+ })
255
+ expect(g.party?.key).toBe('customer_id')
256
+ expect(g.essential.map((f) => f.key)).toEqual(['invoice_date', 'due_date', 'fiscal_data.metodo_pago', 'fiscal_data.forma_pago'])
257
+ expect(g.advanced.map((f) => f.key)).toEqual(['currency_code', 'fiscal_data.uso_cfdi'])
258
+ expect(g.notes.map((f) => f.key)).toEqual(['notes'])
259
+ })
260
+
261
+ it('un origen sin dato de impuesto toma la tasa de la org; uno con impuesto conserva el suyo', () => {
262
+ const [wo] = linesFromSource([{ id: 'a', description: 'Mano de obra', quantity: 1, unit_price: 100 }], {}, { defaultTaxRate: 0.16 })
263
+ expect(wo.tax_rate).toBe(0.16)
264
+ const [exempt] = linesFromSource([{ id: 'b', description: 'Exento', quantity: 1, unit_price: 100, subtotal: 100, tax_amount: 0 }], {}, { defaultTaxRate: 0.16 })
265
+ expect(exempt.tax_rate).toBe(0)
266
+ })
267
+ })
@@ -0,0 +1,57 @@
1
+ // @vitest-environment happy-dom
2
+ // Retest Pitsline r5: «Registrar pago» desde la factura mostraba el UUID en
3
+ // «Factura a abonar», y la lista / la pestaña REP no se refrescaban con lo que
4
+ // el servidor escribe después de la acción (estado tras timbrar, REP tras pagar).
5
+ import { afterEach, describe, expect, it, vi } from 'vitest'
6
+ import { seedOptionFromRecord } from '../action-modal-dispatcher'
7
+ import { emitRecordMutationSettled, subscribeRecordMutations, RECORD_MUTATION_SETTLE_DELAYS } from '../record-mutation-events'
8
+ import type { ActionFieldDef } from '../types'
9
+
10
+ const invoiceField = {
11
+ key: 'invoice_id',
12
+ type: 'dynamic_select',
13
+ ref: 'Invoice',
14
+ options: { source: 'Invoice', value: 'id', label: 'number' },
15
+ } as unknown as ActionFieldDef
16
+
17
+ describe('seedOptionFromRecord — la propia fila', () => {
18
+ it('un selector sembrado con el id de la fila muestra su folio, no el UUID', () => {
19
+ const record = { id: '68a02222-855e-4af9-b086-31526f0dceb5', number: 'FAC-00015', total: 823.6 }
20
+ expect(seedOptionFromRecord(invoiceField, record.id, record)).toEqual(
21
+ expect.objectContaining({ id: record.id, value: record.id, label: 'FAC-00015' }),
22
+ )
23
+ })
24
+ it('sin options.label usa la primera columna de nombre', () => {
25
+ const field = { key: 'invoice_id', type: 'dynamic_select', ref: 'Invoice' } as unknown as ActionFieldDef
26
+ expect(seedOptionFromRecord(field, 'i1', { id: 'i1', folio: 'A-7' })?.label).toBe('A-7')
27
+ })
28
+ it('otro valor (no la propia fila) sigue resolviendo por el objeto hermano', () => {
29
+ const record = { id: 'pay-1', invoice: { value: 'i9', label: 'FAC-00009' } }
30
+ expect(seedOptionFromRecord(invoiceField, 'i9', record)?.label).toBe('FAC-00009')
31
+ expect(seedOptionFromRecord(invoiceField, 'i8', { id: 'pay-1' })).toBeUndefined()
32
+ })
33
+ })
34
+
35
+ describe('emitRecordMutationSettled', () => {
36
+ afterEach(() => vi.useRealTimers())
37
+ it('anuncia ahora y otra vez cuando el servidor ya escribió lo derivado', () => {
38
+ vi.useFakeTimers()
39
+ const seen = vi.fn()
40
+ const off = subscribeRecordMutations('invoices', seen)
41
+ emitRecordMutationSettled('invoices', 'update')
42
+ expect(seen).toHaveBeenCalledTimes(1)
43
+ vi.advanceTimersByTime(RECORD_MUTATION_SETTLE_DELAYS[RECORD_MUTATION_SETTLE_DELAYS.length - 1])
44
+ expect(seen).toHaveBeenCalledTimes(1 + RECORD_MUTATION_SETTLE_DELAYS.length)
45
+ off()
46
+ })
47
+ it('se puede cancelar', () => {
48
+ vi.useFakeTimers()
49
+ const seen = vi.fn()
50
+ const off = subscribeRecordMutations('invoices', seen)
51
+ const cancel = emitRecordMutationSettled('invoices', 'update')
52
+ cancel()
53
+ vi.advanceTimersByTime(10_000)
54
+ expect(seen).toHaveBeenCalledTimes(1)
55
+ off()
56
+ })
57
+ })
@@ -0,0 +1,137 @@
1
+ // @vitest-environment happy-dom
2
+ // Retest Pitsline r6 (P0 UX): «Cargar desde» venta/OT/cotización debe dejar el
3
+ // CLIENTE elegido (su nombre en el selector y su tarjeta), aunque los renglones
4
+ // del origen fallen; y el selector «Factura a abonar» de la NC abierta desde una
5
+ // factura muestra el folio, no el UUID. El selector es el dynamic_select real
6
+ // (cerrado: no consulta opciones), así que la etiqueta sale de lo que el editor
7
+ // ya sabe del registro.
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
10
+
11
+ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
12
+ vi.mock('react-i18next', () => {
13
+ const value = {
14
+ t: (k: string, o?: Record<string, any>) => {
15
+ let s: string = o?.defaultValue ?? k
16
+ for (const [key, v] of Object.entries(o ?? {})) s = s.replace(`{{${key}}}`, String(v))
17
+ return s
18
+ },
19
+ i18n: { language: 'es' },
20
+ }
21
+ return { useTranslation: () => value }
22
+ })
23
+
24
+ import { DocumentFormDialog } from '../document-form-dialog'
25
+ import { ApiProvider, type ApiClient } from '../api-context'
26
+ import { TaxRateContext } from '../org-runtime-context'
27
+ import { seedRecord, sourceHeaderSeeds } from '../business/document-editor-model'
28
+ import type { DocumentFormsManifest, DocumentFormType } from '../types'
29
+
30
+ afterEach(cleanup)
31
+
32
+ const CUSTOMER = '6f1c2a9e-3b7d-4c55-9a51-0d2b8e7f4a10'
33
+ const INVOICE = '0b9d7c1e-5a4f-4e2b-8c3d-7f6e5d4c3b2a'
34
+
35
+ const invoice: DocumentFormType = {
36
+ key: 'invoice',
37
+ label: 'Factura',
38
+ layout: 'editor',
39
+ party: { field: 'customer_id', model: 'customers.Customer', summary: ['tax_id'] },
40
+ fields: [
41
+ { key: 'customer_id', label: 'Cliente', type: 'dynamic_select', ref: 'customers.Customer', required: true } as any,
42
+ { key: 'work_order_id', label: 'Orden de trabajo', type: 'dynamic_select', ref: 'workshop.WorkOrder' } as any,
43
+ ],
44
+ lines: { title: 'Renglones' },
45
+ sources: [
46
+ { key: 'work_order', label: 'Orden de trabajo', model: 'workshop.WorkOrder', lines: 'items', header: { customer_id: 'customer_id' }, link_field: 'work_order_id', line_link_field: 'work_order_item_id' },
47
+ ],
48
+ submit_label: 'Crear factura',
49
+ }
50
+
51
+ const creditNote: DocumentFormType = {
52
+ key: 'credit_note',
53
+ label: 'Nota de crédito',
54
+ layout: 'editor',
55
+ fields: [
56
+ { key: 'invoice_id', label: 'Factura a abonar', type: 'dynamic_select', ref: 'customers.Invoice', required: true, options: { source: 'customers.Invoice', value: 'id', label: 'number' } } as any,
57
+ ],
58
+ lines: { kind: 'credit', title: 'Conceptos' },
59
+ sources: [{ key: 'invoice', label: 'Factura', model: 'customers.Invoice', lines: 'items', link_field: 'invoice_id', line_link_field: 'invoice_item_id' }],
60
+ submit_label: 'Crear nota de crédito',
61
+ }
62
+
63
+ function makeApi(rows: Record<string, unknown[]>) {
64
+ const get = vi.fn(async (url: string) => {
65
+ if (url === '/data/customers.Customer') return { data: { data: [{ id: CUSTOMER, name: 'Transportes del Bajío', tax_id: 'TBA850312KJ9' }] } }
66
+ if (url.startsWith('/data/')) return { data: { data: rows[url.slice(6)] ?? [] } }
67
+ // Los renglones del origen no cargan (host sin source-lines ni relación).
68
+ if (url.endsWith('/source-lines') || url.startsWith('/metadata/table/')) throw new Error('boom')
69
+ return { data: { data: [] } }
70
+ })
71
+ const client = { get, post: vi.fn(), put: vi.fn(), delete: vi.fn() }
72
+ return client as unknown as ApiClient
73
+ }
74
+
75
+ function open(model: string, type: DocumentFormType, api: ApiClient, initialSource: { key: string; id: string }) {
76
+ const forms: DocumentFormsManifest = { lines_field: 'items', types: [type] }
77
+ return render(
78
+ <ApiProvider client={api}>
79
+ <TaxRateContext.Provider value={0.16}>
80
+ <DocumentFormDialog open onOpenChange={() => {}} model={model} forms={forms} initialType={type.key} initialSource={initialSource} currency="MXN" />
81
+ </TaxRateContext.Provider>
82
+ </ApiProvider>,
83
+ )
84
+ }
85
+
86
+ const trigger = (key: string) => document.getElementById(key) as HTMLElement
87
+
88
+ describe('«Cargar desde» llena el cliente', () => {
89
+ it('OT: el selector muestra el cliente por nombre y su tarjeta carga aunque los renglones fallen', async () => {
90
+ const api = makeApi({ 'workshop.WorkOrder': [{ id: 'wo-1', order_number: 'OT-00031', customer_id: CUSTOMER }] })
91
+ open('customers.Invoice', invoice, api, { key: 'work_order', id: 'wo-1' })
92
+ await waitFor(() => expect(trigger('customer_id').textContent).toContain('Transportes del Bajío'))
93
+ expect(trigger('customer_id').textContent).not.toContain(CUSTOMER)
94
+ expect(document.querySelector('[data-slot="party-card"]')).toBeTruthy()
95
+ expect(trigger('work_order_id').textContent).toContain('OT-00031')
96
+ })
97
+
98
+ it('el nombre del cliente que el host sirve junto a la FK se usa de inmediato', async () => {
99
+ const api = makeApi({
100
+ 'workshop.WorkOrder': [{ id: 'wo-1', customer_id: CUSTOMER, customer: { value: CUSTOMER, label: 'Transportes del Bajío' } }],
101
+ })
102
+ open('customers.Invoice', invoice, api, { key: 'work_order', id: 'wo-1' })
103
+ await waitFor(() => expect(trigger('customer_id').textContent).toContain('Transportes del Bajío'))
104
+ })
105
+ })
106
+
107
+ describe('NC desde la factura', () => {
108
+ it('«Factura a abonar» muestra el folio, no el UUID', async () => {
109
+ const api = makeApi({ 'customers.Invoice': [{ id: INVOICE, number: 'FAC-00014', customer_id: CUSTOMER }] })
110
+ open('fiscal_mexico.CreditNote', creditNote, api, { key: 'invoice', id: INVOICE })
111
+ await waitFor(() => expect(trigger('invoice_id').textContent).toContain('FAC-00014'))
112
+ expect(trigger('invoice_id').textContent).not.toContain(INVOICE)
113
+ expect(screen.queryByText(INVOICE)).toBeNull()
114
+ })
115
+ })
116
+
117
+ describe('reglas puras', () => {
118
+ it('sourceHeaderSeeds: hermano {value,label} de la FK y folio del origen', () => {
119
+ const seeds = sourceHeaderSeeds(
120
+ invoice.sources![0],
121
+ { id: 'wo-1', order_number: 'OT-7', customer_id: 'c-1', customer: { value: 'c-1', label: 'Ana' } },
122
+ 'wo-1',
123
+ invoice.fields,
124
+ )
125
+ expect(seeds).toEqual({ customer_id: { value: 'c-1', label: 'Ana' }, work_order_id: { value: 'wo-1', label: 'OT-7' } })
126
+ expect(sourceHeaderSeeds(invoice.sources![0], { id: 'wo-1', customer_id: 'c-1' }, 'wo-1')).toEqual({})
127
+ })
128
+
129
+ it('seedRecord: siembra el hermano y conserva el registro de origen', () => {
130
+ expect(seedRecord({}, undefined)).toBeUndefined()
131
+ expect(seedRecord({ customer_id: { value: 'c-1', label: 'Ana' } }, { id: 'inv-1', number: 'F-1' })).toEqual({
132
+ id: 'inv-1',
133
+ number: 'F-1',
134
+ customer: { value: 'c-1', label: 'Ana' },
135
+ })
136
+ })
137
+ })
@@ -1,17 +1,21 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { linkNavigation } from '../dynamic-row-actions'
3
3
 
4
- // «Crear desde»: el botón de la fila del origen es una acción link con query
5
- // (`/m/invoices?create=invoice&from=sales_order&from_id={id}`). En `to` el
6
- // router la tomaba como parte del path y `$model` quedaba "invoices?create=…".
7
4
  describe('linkNavigation', () => {
8
- it('separa la query como search', () => {
5
+ it('parte /m/<tabla>?create… en ruta parametrizada + search (retest r6b)', () => {
9
6
  expect(linkNavigation('/m/invoices?create=invoice&from=sales_order&from_id=so-1')).toEqual({
10
- to: '/m/invoices',
7
+ to: '/m/$model',
8
+ params: { model: 'invoices' },
11
9
  search: { create: 'invoice', from: 'sales_order', from_id: 'so-1' },
12
10
  })
11
+ expect(linkNavigation('/m/credit_notes?create=credit_note&from=invoice&from_id=fac-1')).toEqual({
12
+ to: '/m/$model',
13
+ params: { model: 'credit_notes' },
14
+ search: { create: 'credit_note', from: 'invoice', from_id: 'fac-1' },
15
+ })
13
16
  })
14
- it('sin query deja el destino igual', () => {
17
+ it('deja paths que no son /m/<tabla> como pathname', () => {
15
18
  expect(linkNavigation('/m/quotes/abc')).toEqual({ to: '/m/quotes/abc' })
19
+ expect(linkNavigation('/settings')).toEqual({ to: '/settings' })
16
20
  })
17
21
  })
@@ -14,7 +14,7 @@
14
14
  //
15
15
  // The host injects its axios-like client via <ApiProvider>; we no longer
16
16
  // depend on a bundler alias to `@/lib/api`.
17
- import { emitRecordMutation } from './record-mutation-events'
17
+ import { emitRecordMutationSettled } from './record-mutation-events'
18
18
  import { Suspense, useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
19
19
  import { useTranslation } from 'react-i18next'
20
20
  import {
@@ -415,9 +415,11 @@ export function ActionModalDispatcher({
415
415
  }: ActionModalProps) {
416
416
  // Every successful action may have created/updated/deleted a record of
417
417
  // `model` (federated component, wizard, generic form or confirmation), so
418
- // announce it: mounted lists of the model reload on their own (#1020).
418
+ // announce it: mounted lists of the model reload on their own (#1020) —
419
+ // and again once the server's event subscribers had time to write what the
420
+ // action triggers (estado tras timbrar, REP tras un pago).
419
421
  const onSuccess = useCallback(() => {
420
- emitRecordMutation(model, 'update')
422
+ emitRecordMutationSettled(model, 'update')
421
423
  onSuccessProp?.()
422
424
  }, [model, onSuccessProp])
423
425
  // Reactive read: a federated remote usually registers AFTER the first
@@ -1640,12 +1642,39 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1640
1642
  )
1641
1643
  }
1642
1644
 
1643
- function seedOptionFromRecord(
1645
+ /** Columnas con las que se nombra una fila cuando el selector no declara `options.label`. */
1646
+ const RECORD_LABEL_COLUMNS = ['number', 'folio', 'name', 'title', 'code', 'label']
1647
+
1648
+ /**
1649
+ * La etiqueta de la PROPIA fila cuando el campo se sembró con su id
1650
+ * («Factura a abonar» al registrar un pago desde la factura): la columna que el
1651
+ * selector usa de etiqueta (`options.label`) o la primera de nombre que tenga.
1652
+ * Sin esto el selector mostraba el UUID crudo (retest Pitsline r5).
1653
+ */
1654
+ function selfLabel(field: ActionFieldDef, record: Record<string, any>): string {
1655
+ const opts = (field as { options?: unknown }).options
1656
+ const declared =
1657
+ opts && !Array.isArray(opts) && typeof opts === 'object' ? (opts as { label?: unknown }).label : undefined
1658
+ const cols = typeof declared === 'string' && declared ? [declared, ...RECORD_LABEL_COLUMNS] : RECORD_LABEL_COLUMNS
1659
+ for (const c of cols) {
1660
+ const v = unwrapRecordScalar(record[c])
1661
+ if ((typeof v === 'string' && v.trim()) || typeof v === 'number') return String(v)
1662
+ }
1663
+ return ''
1664
+ }
1665
+
1666
+ export function seedOptionFromRecord(
1644
1667
  field: ActionFieldDef,
1645
1668
  value: any,
1646
1669
  record?: Record<string, any>,
1647
1670
  ): import('./use-options-resolver').ResolvedOption | undefined {
1648
- if (!record || !field.key.endsWith('_id')) return undefined
1671
+ if (!record) return undefined
1672
+ const selfId = unwrapRecordScalar(record.id)
1673
+ if (value != null && value !== '' && selfId != null && String(selfId) === String(value)) {
1674
+ const label = selfLabel(field, record)
1675
+ if (label) return { id: String(value), value: String(value), label, name: label }
1676
+ }
1677
+ if (!field.key.endsWith('_id')) return undefined
1649
1678
  const siblingKey = field.key.replace(/_id$/, '')
1650
1679
  const sib = record[siblingKey]
1651
1680
  if (!sib || typeof sib !== 'object') return undefined