@asteby/metacore-runtime-react 49.0.0 → 49.2.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 (47) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/action-modal-dispatcher.d.ts +18 -1
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +66 -11
  5. package/dist/business/document-editor-model.d.ts +6 -1
  6. package/dist/business/document-editor-model.d.ts.map +1 -1
  7. package/dist/business/document-editor-model.js +48 -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 +72 -26
  14. package/dist/calendar-date.d.ts +12 -0
  15. package/dist/calendar-date.d.ts.map +1 -1
  16. package/dist/calendar-date.js +36 -0
  17. package/dist/document-form-dialog.d.ts +23 -2
  18. package/dist/document-form-dialog.d.ts.map +1 -1
  19. package/dist/document-form-dialog.js +49 -8
  20. package/dist/dynamic-crud-page.d.ts +7 -0
  21. package/dist/dynamic-crud-page.d.ts.map +1 -1
  22. package/dist/dynamic-crud-page.js +5 -4
  23. package/dist/index.d.ts +4 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +4 -3
  26. package/dist/record-mutation-events.d.ts +12 -0
  27. package/dist/record-mutation-events.d.ts.map +1 -1
  28. package/dist/record-mutation-events.js +18 -0
  29. package/dist/types.d.ts +7 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/__tests__/document-editor-model.test.ts +27 -0
  33. package/src/__tests__/document-editor.test.tsx +60 -0
  34. package/src/__tests__/document-form-create-model.test.tsx +143 -0
  35. package/src/__tests__/field-default-today.test.tsx +116 -0
  36. package/src/__tests__/invoice-document-editor.test.tsx +267 -0
  37. package/src/__tests__/r5-self-label-settled.test.ts +57 -0
  38. package/src/action-modal-dispatcher.tsx +78 -11
  39. package/src/business/document-editor-model.ts +50 -7
  40. package/src/business/document-editor-panels.tsx +49 -0
  41. package/src/business/document-editor.tsx +249 -166
  42. package/src/calendar-date.ts +36 -0
  43. package/src/document-form-dialog.tsx +72 -8
  44. package/src/dynamic-crud-page.tsx +20 -2
  45. package/src/index.ts +8 -0
  46. package/src/record-mutation-events.ts +23 -0
  47. package/src/types.ts +7 -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
+ })
@@ -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 {
@@ -93,6 +93,8 @@ import { requestFederatedAction } from './federated-action-loader'
93
93
  import { useInstalledAddons } from './installed-addons-context'
94
94
  import { federatedModalComponent, resolveFederatedModal } from './primitives/contributions'
95
95
  import { installedPredicate, useContributionsVersion } from './primitives/use-contributions'
96
+ import { isTodayToken, todayInZone } from './calendar-date'
97
+ import { useTimeZone } from './org-runtime-context'
96
98
 
97
99
  export type { ActionMetadata, ActionModalProps }
98
100
 
@@ -413,9 +415,11 @@ export function ActionModalDispatcher({
413
415
  }: ActionModalProps) {
414
416
  // Every successful action may have created/updated/deleted a record of
415
417
  // `model` (federated component, wizard, generic form or confirmation), so
416
- // 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).
417
421
  const onSuccess = useCallback(() => {
418
- emitRecordMutation(model, 'update')
422
+ emitRecordMutationSettled(model, 'update')
419
423
  onSuccessProp?.()
420
424
  }, [model, onSuccessProp])
421
425
  // Reactive read: a federated remote usually registers AFTER the first
@@ -1021,6 +1025,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1021
1025
  const reasonPrompt = useReasonPrompt()
1022
1026
  const supervisor = useSupervisor()
1023
1027
  const branchGate = useBranchCreateGate()
1028
+ const orgTimeZone = useTimeZone()
1024
1029
  const [formData, setFormData] = useState<Record<string, any>>({})
1025
1030
  const [executing, setExecuting] = useState(false)
1026
1031
  // Per-field validation errors (localized), shown inline under each input.
@@ -1058,11 +1063,11 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1058
1063
 
1059
1064
  useEffect(() => {
1060
1065
  if (open && action.fields) {
1061
- setFormData(buildFieldDefaults(action.fields, record, model))
1066
+ setFormData(buildFieldDefaults(action.fields, record, model, { timeZone: orgTimeZone }))
1062
1067
  setFieldErrors({})
1063
1068
  setFormError(undefined)
1064
1069
  }
1065
- }, [open, action.fields, record, model])
1070
+ }, [open, action.fields, record, model, orgTimeZone])
1066
1071
 
1067
1072
  // Lines the list row does not carry: load them from the declared relation
1068
1073
  // (see prefillRelationRequests) and seed the line-items grid.
@@ -1353,10 +1358,44 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1353
1358
  )
1354
1359
  }
1355
1360
 
1361
+ /** Context a declared field default is resolved against. */
1362
+ export interface FieldDefaultContext {
1363
+ /** Org IANA timezone: `$today` is the org's day, not the browser's or UTC's. */
1364
+ timeZone?: string
1365
+ /** Clock (tests). */
1366
+ now?: Date
1367
+ }
1368
+
1369
+ const DATE_FIELD_TYPES = new Set(['date', 'datetime', 'timestamp', 'timestamptz'])
1370
+
1371
+ /**
1372
+ * The declared scalar default of a field, resolved: `defaultValue` (host
1373
+ * camelCase) or `default` (raw manifest, the kernel serves action/document-form
1374
+ * fields without renaming it). A date field may declare `$today` / `today` and
1375
+ * gets the org's calendar day. An unknown `$token` never lands in a date field
1376
+ * (undefined): a literal "$today" posted as invoice_date is a 422, an empty
1377
+ * field is just a required field to fill.
1378
+ */
1379
+ export function resolveFieldDefault(field: ActionFieldDef, ctx: FieldDefaultContext = {}): unknown {
1380
+ const f = field as { defaultValue?: unknown; default?: unknown }
1381
+ const raw = f.defaultValue ?? f.default
1382
+ if (raw === undefined || raw === null) return undefined
1383
+ if (DATE_FIELD_TYPES.has(String(field.type ?? '').toLowerCase()) && typeof raw === 'string') {
1384
+ if (isTodayToken(raw)) return todayInZone(ctx.timeZone, ctx.now)
1385
+ if (raw.trim().startsWith('$')) return undefined
1386
+ }
1387
+ return raw
1388
+ }
1389
+
1356
1390
  // buildFieldDefaults seeds formData for a set of action fields, honoring the
1357
1391
  // same line-items prefill spec + boolean/empty rules GenericActionModal uses, so
1358
1392
  // wizard steps and single-page forms initialize identically.
1359
- export function buildFieldDefaults(fields: ActionFieldDef[], record: any, model?: string): Record<string, any> {
1393
+ export function buildFieldDefaults(
1394
+ fields: ActionFieldDef[],
1395
+ record: any,
1396
+ model?: string,
1397
+ ctx: FieldDefaultContext = {},
1398
+ ): Record<string, any> {
1360
1399
  const defaults: Record<string, any> = {}
1361
1400
  for (const field of fields) {
1362
1401
  if (isLineItemsField(field)) {
@@ -1373,7 +1412,7 @@ export function buildFieldDefaults(fields: ActionFieldDef[], record: any, model?
1373
1412
  defaults[field.key] = fromRecord
1374
1413
  continue
1375
1414
  }
1376
- defaults[field.key] = field.defaultValue ?? (field.type === 'boolean' ? false : '')
1415
+ defaults[field.key] = resolveFieldDefault(field, ctx) ?? (field.type === 'boolean' ? false : '')
1377
1416
  }
1378
1417
  return defaults
1379
1418
  }
@@ -1404,6 +1443,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1404
1443
  const api = useApi()
1405
1444
  const reasonPrompt = useReasonPrompt()
1406
1445
  const supervisor = useSupervisor()
1446
+ const orgTimeZone = useTimeZone()
1407
1447
  const steps = action.steps ?? []
1408
1448
  const [stepIndex, setStepIndex] = useState(0)
1409
1449
  const [formData, setFormData] = useState<Record<string, any>>({})
@@ -1416,10 +1456,10 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1416
1456
  useEffect(() => {
1417
1457
  if (!open) return
1418
1458
  const allFields = steps.flatMap((s) => s.fields ?? [])
1419
- setFormData(buildFieldDefaults(allFields, record, model))
1459
+ setFormData(buildFieldDefaults(allFields, record, model, { timeZone: orgTimeZone }))
1420
1460
  setStepIndex(0)
1421
1461
  setFormError(undefined)
1422
- }, [open, action.steps, record, model])
1462
+ }, [open, action.steps, record, model, orgTimeZone])
1423
1463
 
1424
1464
  const updateField = (key: string, value: any) =>
1425
1465
  setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
@@ -1602,12 +1642,39 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1602
1642
  )
1603
1643
  }
1604
1644
 
1605
- 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(
1606
1667
  field: ActionFieldDef,
1607
1668
  value: any,
1608
1669
  record?: Record<string, any>,
1609
1670
  ): import('./use-options-resolver').ResolvedOption | undefined {
1610
- 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
1611
1678
  const siblingKey = field.key.replace(/_id$/, '')
1612
1679
  const sib = record[siblingKey]
1613
1680
  if (!sib || typeof sib !== 'object') return undefined
@@ -54,7 +54,9 @@ function declaredDefault(f: ActionFieldDef): unknown {
54
54
 
55
55
  /**
56
56
  * Divulgación progresiva: ¿el campo va plegado en «Opciones fiscales»?
57
- * - campos de extensión (`fiscal_data.*`) que aportan otros addons;
57
+ * - campos de extensión (`fiscal_data.*`) que aportan otros addons (los que
58
+ * el propio tipo declara obligatorios, p. ej. método y forma de pago de la
59
+ * factura, se marcan esenciales en splitEditorFields);
58
60
  * - `section: "fiscal" | "advanced"`;
59
61
  * - catálogos opcionales que ya traen un default (método, serie, relación…):
60
62
  * el usuario no necesita tocarlos para guardar.
@@ -84,7 +86,10 @@ export function splitEditorFields(
84
86
  fields: readonly ActionFieldDef[],
85
87
  opts: { partyField?: string; essentialKeys?: readonly string[]; extensionFields?: readonly ActionFieldDef[] } = {},
86
88
  ): EditorFieldGroups {
87
- const essentialKeys = new Set(opts.essentialKeys ?? [])
89
+ // Lo obligatorio que el tipo declara siempre está a la vista, aunque sea de
90
+ // extensión (`fiscal_data.metodo_pago`): es lo que el usuario debe decidir.
91
+ // Las extensiones que llegan del metadata (no declaradas) siguen plegadas.
92
+ const essentialKeys = new Set([...(opts.essentialKeys ?? []), ...fields.filter((f) => f.required).map((f) => f.key)])
88
93
  const out: EditorFieldGroups = { essential: [], advanced: [], notes: [] }
89
94
  const seen = new Set<string>()
90
95
  for (const f of [...fields, ...(opts.extensionFields ?? [])]) {
@@ -200,7 +205,7 @@ export function creditStatus(
200
205
  export function linesFromSource(
201
206
  rows: unknown,
202
207
  map: Record<string, string> = {},
203
- opts: { kind?: DocumentFormLines['kind']; discountMode?: DocumentFormLines['discount_mode'] } = {},
208
+ opts: { kind?: DocumentFormLines['kind']; discountMode?: DocumentFormLines['discount_mode']; defaultTaxRate?: number } = {},
204
209
  ): LineItem[] {
205
210
  if (!Array.isArray(rows)) return []
206
211
  return rows.filter((raw) => !isFullyConsumed(raw)).map((raw) => {
@@ -212,13 +217,18 @@ export function linesFromSource(
212
217
  if (!hasValue(rate)) {
213
218
  const sub = toAmount(get('subtotal'))
214
219
  const tax = toAmount(get('tax_amount'))
215
- rate = sub > 0 ? Math.round((tax / sub) * 10000) / 10000 : 0
220
+ // Un origen sin dato de impuesto (p. ej. una OT de taller: solo
221
+ // cantidad y precio) toma la tasa de la org, como un renglón nuevo.
222
+ rate = hasValue(get('tax_amount'))
223
+ ? sub > 0 ? Math.round((tax / sub) * 10000) / 10000 : 0
224
+ : opts.defaultTaxRate ?? 0
216
225
  }
217
226
  const r2 = toAmount(rate)
227
+ const product = get('product_id')
218
228
  const line = makeLine({
219
- product_id: get('product_id') ?? undefined,
229
+ product_id: refValue(product),
220
230
  sku: get('sku') ?? undefined,
221
- description: String(get('description') ?? get('product_name') ?? ''),
231
+ description: String(get('description') ?? get('product_name') ?? refLabel(product) ?? refLabel(r.product) ?? ''),
222
232
  quantity: qty,
223
233
  unit_price: toAmount(get('unit_price')),
224
234
  discount: toAmount(get('discount') ?? 0),
@@ -236,11 +246,40 @@ export function linesFromSource(
236
246
  })
237
247
  }
238
248
 
249
+ /** Id de una celda de relación (`{value,label}`, `{id,name}` o el id plano). */
250
+ function refValue(v: unknown): string | undefined {
251
+ if (v == null || v === '') return undefined
252
+ if (typeof v === 'object') {
253
+ const o = v as Record<string, unknown>
254
+ const id = o.value ?? o.id
255
+ return id == null || id === '' ? undefined : String(id)
256
+ }
257
+ return String(v)
258
+ }
259
+
260
+ /**
261
+ * Nombre de una celda de relación ya resuelta por el host (`{label}`/`{name}`).
262
+ * Un renglón de origen sin descripción propia (InvoiceItem solo lleva el
263
+ * producto) se nombra con su producto en vez de entrar en blanco.
264
+ */
265
+ function refLabel(v: unknown): string | undefined {
266
+ if (!v || typeof v !== 'object') return undefined
267
+ const o = v as Record<string, unknown>
268
+ const l = o.label ?? o.name
269
+ return typeof l === 'string' && l.trim() ? l : undefined
270
+ }
271
+
239
272
  function isFullyConsumed(raw: unknown): boolean {
240
273
  const r = (raw ?? {}) as Record<string, any>
241
274
  return hasValue(r.remaining_quantity) && toAmount(r.remaining_quantity) <= 0
242
275
  }
243
276
 
277
+ /** El editor ocupa toda la pantalla: documentos comerciales con renglones (factura, cotización, pedido, OC). */
278
+ export function isFullscreenEditor(type: DocumentFormType, forms: Pick<DocumentFormsManifest, 'lines_field'>): boolean {
279
+ const kind = editorLinesConfig(type, forms)?.kind ?? 'sale'
280
+ return !!type.lines && (kind === 'sale' || kind === 'purchase')
281
+ }
282
+
244
283
  /** Cuántos renglones del origen se cargaron y cuántos se omitieron por estar ya cubiertos. */
245
284
  export function sourceLoadSummary(rows: unknown): { loaded: number; covered: number } {
246
285
  if (!Array.isArray(rows)) return { loaded: 0, covered: 0 }
@@ -267,7 +306,11 @@ export function localIssues(lines: LineItem[], opts: { requireLines?: boolean; r
267
306
  issues.push({ field: 'lines', severity: 'error', message: 'Agrega al menos un renglón.' })
268
307
  }
269
308
  items.forEach((l, i) => {
270
- if (!l.description.trim()) issues.push({ field: `lines.${i}`, severity: 'error', message: `Renglón ${i + 1}: falta la descripción.` })
309
+ // Un renglón con producto se describe con él (el servidor lo nombra
310
+ // desde el catálogo): solo un renglón libre exige texto, como en el
311
+ // editor de renglones (validateLineItems).
312
+ if (!l.description.trim() && !l.product_id)
313
+ issues.push({ field: `lines.${i}`, severity: 'error', message: `Renglón ${i + 1}: elige un producto o escribe la descripción.` })
271
314
  if (toAmount(l.unit_price) <= 0) issues.push({ field: `lines.${i}`, severity: 'warning', message: `Renglón ${i + 1}: precio en cero.` })
272
315
  if (l.max_quantity != null && toAmount(l.quantity) > toAmount(l.max_quantity)) {
273
316
  issues.push({