@asteby/metacore-runtime-react 49.7.0 → 49.8.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.
- package/CHANGELOG.md +6 -0
- package/dist/business/document-editor-model.d.ts +27 -2
- package/dist/business/document-editor-model.d.ts.map +1 -1
- package/dist/business/document-editor-model.js +53 -3
- package/dist/business/document-editor.d.ts +7 -2
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +116 -21
- package/dist/business/document-lines.d.ts +21 -1
- package/dist/business/document-lines.d.ts.map +1 -1
- package/dist/business/document-lines.js +69 -7
- package/dist/business/line-items-editor.d.ts +21 -1
- package/dist/business/line-items-editor.d.ts.map +1 -1
- package/dist/business/line-items-editor.js +74 -15
- package/dist/business/line-items.d.ts +28 -0
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +1 -0
- package/dist/business/line-product-cell.d.ts +6 -0
- package/dist/business/line-product-cell.d.ts.map +1 -1
- package/dist/business/line-product-cell.js +9 -3
- package/dist/business/product-options.js +1 -1
- package/dist/dialogs/dynamic-record.js +1 -1
- package/dist/field-grid.d.ts +4 -3
- package/dist/field-grid.d.ts.map +1 -1
- package/dist/field-grid.js +1 -1
- package/dist/record-picker.d.ts +2 -0
- package/dist/record-picker.d.ts.map +1 -1
- package/dist/record-picker.js +11 -4
- package/package.json +1 -1
- package/src/__tests__/document-editor-inline-selects.test.tsx +11 -4
- package/src/__tests__/line-row-create-edit.test.tsx +312 -0
- package/src/business/document-editor-model.ts +60 -2
- package/src/business/document-editor.tsx +132 -17
- package/src/business/document-lines.ts +79 -7
- package/src/business/line-items-editor.tsx +158 -30
- package/src/business/line-items.ts +31 -0
- package/src/business/line-product-cell.tsx +32 -5
- package/src/business/product-options.tsx +1 -1
- package/src/dialogs/dynamic-record.tsx +1 -1
- package/src/field-grid.tsx +5 -4
- package/src/record-picker.tsx +16 -3
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Renglón del DocumentEditor «como las apps top»:
|
|
4
|
+
// - crear el producto desde la celda («Crear producto «texto»» / «+»): el
|
|
5
|
+
// producto nuevo entra en ESE renglón y lo llena igual que al elegirlo;
|
|
6
|
+
// - editar el producto del renglón (lápiz en la celda): el renglón toma los
|
|
7
|
+
// valores nuevos salvo lo que el usuario sobrescribió, con aviso «aplicar»;
|
|
8
|
+
// - la tarjeta de la contraparte con etiquetas y catálogos legibles;
|
|
9
|
+
// - placeholder corto y unidad con su etiqueta (nunca la clave cruda).
|
|
10
|
+
import { useState } from 'react'
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
13
|
+
|
|
14
|
+
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
|
|
15
|
+
vi.mock('react-i18next', () => {
|
|
16
|
+
const t = (k: string, o?: Record<string, any>) => {
|
|
17
|
+
let s: string = o?.defaultValue ?? k
|
|
18
|
+
for (const [key, v] of Object.entries(o ?? {})) s = s.replace(`{{${key}}}`, String(v))
|
|
19
|
+
return s
|
|
20
|
+
}
|
|
21
|
+
const value = { t, i18n: { language: 'es' } }
|
|
22
|
+
return { useTranslation: () => value }
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
import { DocumentLinesGrid } from '../business/line-items-editor'
|
|
26
|
+
import { DocumentEditor } from '../business/document-editor'
|
|
27
|
+
import { makeLine, serializeLineItems, type LineItem } from '../business/line-items'
|
|
28
|
+
import { applyCatalogPending, applyProductToLine, lineOverrides, refreshLineFromProduct } from '../business/document-lines'
|
|
29
|
+
import { fieldDisplayMeta, formatOptionValue, linesFromSource, partySummaryRows } from '../business/document-editor-model'
|
|
30
|
+
import type { ProductResult } from '../business/product-search'
|
|
31
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
32
|
+
import type { DocumentFormType } from '../types'
|
|
33
|
+
|
|
34
|
+
afterEach(cleanup)
|
|
35
|
+
|
|
36
|
+
const tire: ProductResult = { id: 'p1', name: 'Llanta 205/55R16', sku: 'LL-205', price: 1500, tax_rate: 0.16, unit: 'unit' }
|
|
37
|
+
|
|
38
|
+
function Grid(props: { initial?: LineItem[]; onCreateProduct?: any; onEditProduct?: any; unitLabel?: (u: string) => string | undefined }) {
|
|
39
|
+
const [lines, setLines] = useState(props.initial ?? [])
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
<DocumentLinesGrid
|
|
43
|
+
value={lines}
|
|
44
|
+
onChange={setLines}
|
|
45
|
+
search={vi.fn(async () => [])}
|
|
46
|
+
columns={['discount', 'tax']}
|
|
47
|
+
currency="MXN"
|
|
48
|
+
onCreateProduct={props.onCreateProduct}
|
|
49
|
+
onEditProduct={props.onEditProduct}
|
|
50
|
+
unitLabel={props.unitLabel}
|
|
51
|
+
/>
|
|
52
|
+
<output data-testid="state">{JSON.stringify(lines)}</output>
|
|
53
|
+
</>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
const state = () => JSON.parse(screen.getByTestId('state').textContent ?? '[]') as LineItem[]
|
|
57
|
+
|
|
58
|
+
async function typeAndCreate(input: HTMLElement, text: string) {
|
|
59
|
+
input.focus()
|
|
60
|
+
fireEvent.change(input, { target: { value: text } })
|
|
61
|
+
const create = await waitFor(() => {
|
|
62
|
+
const el = document.querySelector('[data-slot="picker-create"]') as HTMLElement | null
|
|
63
|
+
if (!el) throw new Error('sin pie «Crear»')
|
|
64
|
+
return el
|
|
65
|
+
}, { timeout: 2000 })
|
|
66
|
+
expect(create.textContent).toContain('Crear producto')
|
|
67
|
+
expect(create.textContent).toContain(`«${text}»`)
|
|
68
|
+
fireEvent.click(create)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('renglón — crear producto desde la celda', () => {
|
|
72
|
+
it('el renglón vacío del final: «Crear producto «texto»» abre el alta prellenada y el producto nuevo llena el renglón', async () => {
|
|
73
|
+
let resolve!: (p: ProductResult) => void
|
|
74
|
+
const onCreate = vi.fn(() => new Promise<ProductResult>((r) => (resolve = r)))
|
|
75
|
+
render(<Grid onCreateProduct={onCreate} unitLabel={(u) => (u === 'unit' ? 'pza' : undefined)} />)
|
|
76
|
+
await typeAndCreate(screen.getByRole('combobox', { name: 'Buscar producto' }), 'Llanta nueva')
|
|
77
|
+
expect(onCreate).toHaveBeenCalledWith('Llanta nueva')
|
|
78
|
+
// Mientras el modal está abierto no se agrega nada.
|
|
79
|
+
expect(state()).toHaveLength(0)
|
|
80
|
+
await act(async () => resolve({ ...tire, id: 'p9', name: 'Llanta nueva', sku: 'LL-NEW', price: 2450 }))
|
|
81
|
+
await waitFor(() => expect(state()).toHaveLength(1))
|
|
82
|
+
expect(state()[0]).toMatchObject({ product_id: 'p9', description: 'Llanta nueva', sku: 'LL-NEW', unit_price: 2450, catalog_price: 2450, tax_rate: 0.16, unit: 'unit', quantity: 1 })
|
|
83
|
+
// La unidad con su etiqueta, nunca la clave cruda.
|
|
84
|
+
const row = document.querySelector(`[data-line-key="${state()[0].key}"]`)!
|
|
85
|
+
expect(row.textContent).toContain('LL-NEW · pza')
|
|
86
|
+
expect(row.textContent).not.toContain('· unit')
|
|
87
|
+
await waitFor(() => expect((document.activeElement as HTMLElement | null)?.getAttribute('data-cell')).toBe('quantity'))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('un renglón libre (sin producto): el producto creado queda en ESE renglón, no en uno nuevo', async () => {
|
|
91
|
+
const free = makeLine({ description: '', quantity: 3 })
|
|
92
|
+
const onCreate = vi.fn(async () => ({ ...tire, id: 'p7', name: 'Rin 16' }))
|
|
93
|
+
render(<Grid initial={[free]} onCreateProduct={onCreate} />)
|
|
94
|
+
const cell = screen.getAllByRole('combobox', { name: 'Descripción' })[0]
|
|
95
|
+
await typeAndCreate(cell, 'Rin 16')
|
|
96
|
+
await waitFor(() => expect(state()[0].product_id).toBe('p7'))
|
|
97
|
+
expect(state()).toHaveLength(1)
|
|
98
|
+
expect(state()[0]).toMatchObject({ key: free.key, description: 'Rin 16', quantity: 3, unit_price: 1500 })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('el «+» unido al buscador también abre el alta', async () => {
|
|
102
|
+
const onCreate = vi.fn(() => new Promise<ProductResult>(() => {}))
|
|
103
|
+
render(<Grid onCreateProduct={onCreate} />)
|
|
104
|
+
const draft = document.querySelector('[data-slot="line-draft-row"]') as HTMLElement
|
|
105
|
+
fireEvent.click(within(draft).getByRole('button', { name: 'Crear producto' }))
|
|
106
|
+
expect(onCreate).toHaveBeenCalledWith('')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('sin `onCreateProduct` no hay pie ni «+» (otros usos del grid quedan igual)', () => {
|
|
110
|
+
render(<Grid />)
|
|
111
|
+
const draft = document.querySelector('[data-slot="line-draft-row"]') as HTMLElement
|
|
112
|
+
expect(within(draft).queryByRole('button', { name: 'Crear producto' })).toBeNull()
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('renglón — editar el producto desde la celda', () => {
|
|
117
|
+
it('lápiz accesible por teclado; al guardar actualiza lo que sigue igual al catálogo y respeta el precio capturado con aviso «aplicar»', async () => {
|
|
118
|
+
const picked = applyProductToLine(makeLine({ quantity: 2 }), tire)
|
|
119
|
+
let resolve!: (p: ProductResult) => void
|
|
120
|
+
const onEdit = vi.fn(() => new Promise<ProductResult>((r) => (resolve = r)))
|
|
121
|
+
render(<Grid initial={[picked]} onEditProduct={onEdit} />)
|
|
122
|
+
// El usuario cambia el precio a mano (override).
|
|
123
|
+
const price = screen.getByRole('textbox', { name: 'Precio' })
|
|
124
|
+
fireEvent.change(price, { target: { value: '1400' } })
|
|
125
|
+
const pencil = screen.getByRole('button', { name: 'Editar producto' })
|
|
126
|
+
expect(pencil.tagName).toBe('BUTTON')
|
|
127
|
+
expect(pencil.getAttribute('tabindex')).not.toBe('-1')
|
|
128
|
+
fireEvent.click(pencil)
|
|
129
|
+
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ product_id: 'p1' }))
|
|
130
|
+
await act(async () => resolve({ ...tire, name: 'Llanta 205/55R16 XL', sku: 'LL-205XL', price: 1650, tax_rate: 0.08, unit: 'set' }))
|
|
131
|
+
await waitFor(() => expect(state()[0].description).toBe('Llanta 205/55R16 XL'))
|
|
132
|
+
const l = state()[0]
|
|
133
|
+
expect(l).toMatchObject({ sku: 'LL-205XL', tax_rate: 0.08, unit: 'set', catalog_price: 1650, quantity: 2 })
|
|
134
|
+
// Override respetado.
|
|
135
|
+
expect(l.unit_price).toBe(1400)
|
|
136
|
+
const notice = document.querySelector('[data-slot="catalog-pending"]') as HTMLElement
|
|
137
|
+
expect(notice.textContent).toMatch(/Precio de catálogo cambió a .*1,650\.00/)
|
|
138
|
+
fireEvent.click(within(notice).getByRole('button', { name: 'aplicar' }))
|
|
139
|
+
await waitFor(() => expect(state()[0].unit_price).toBe(1650))
|
|
140
|
+
expect(document.querySelector('[data-slot="catalog-pending"]')).toBeNull()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('sin overrides no hay aviso: todo el renglón toma el catálogo nuevo', async () => {
|
|
144
|
+
const picked = applyProductToLine(makeLine(), tire)
|
|
145
|
+
render(<Grid initial={[picked]} onEditProduct={vi.fn(async () => ({ ...tire, price: 1800 }))} />)
|
|
146
|
+
fireEvent.click(screen.getByRole('button', { name: 'Editar producto' }))
|
|
147
|
+
await waitFor(() => expect(state()[0].unit_price).toBe(1800))
|
|
148
|
+
expect(document.querySelector('[data-slot="catalog-pending"]')).toBeNull()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('sin `onEditProduct` no hay lápiz', () => {
|
|
152
|
+
render(<Grid initial={[applyProductToLine(makeLine(), tire)]} />)
|
|
153
|
+
expect(screen.queryByRole('button', { name: 'Editar producto' })).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('refreshLineFromProduct (puro)', () => {
|
|
158
|
+
it('dirty por campo: solo pisa lo que sigue igual a la foto del catálogo', () => {
|
|
159
|
+
const line = { ...applyProductToLine(makeLine(), tire), description: 'Llanta con nombre del cliente' }
|
|
160
|
+
expect([...lineOverrides(line)]).toEqual(['description'])
|
|
161
|
+
const next = refreshLineFromProduct(line, { ...tire, name: 'Llanta nueva', price: 1600 })
|
|
162
|
+
expect(next.description).toBe('Llanta con nombre del cliente')
|
|
163
|
+
expect(next.unit_price).toBe(1600)
|
|
164
|
+
expect(next.catalog_pending).toEqual({ description: 'Llanta nueva' })
|
|
165
|
+
// La foto avanza: el override sigue siéndolo, lo demás queda limpio.
|
|
166
|
+
expect([...lineOverrides(next)]).toEqual(['description'])
|
|
167
|
+
expect(applyCatalogPending(next).description).toBe('Llanta nueva')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('renglón de un documento origen: no cambia cantidad, tope ni vínculo, ni pisa los precios del origen', () => {
|
|
171
|
+
const [src] = linesFromSource([{ id: 'sol-1', product_id: 'p1', product_name: 'Llanta', quantity: 4, remaining_quantity: 2, unit_price: 1300, tax_rate: 0.16 }])
|
|
172
|
+
const next = refreshLineFromProduct(src, { ...tire, price: 1700 })
|
|
173
|
+
expect(next).toMatchObject({ quantity: 2, max_quantity: 2, source_line_id: 'sol-1', unit_price: 1300, description: 'Llanta', product_id: 'p1' })
|
|
174
|
+
expect(next.catalog_pending?.unit_price).toBe(1700)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('la foto y el aviso no viajan al backend', () => {
|
|
178
|
+
const next = refreshLineFromProduct({ ...applyProductToLine(makeLine(), tire), unit_price: 1 }, { ...tire, price: 2 })
|
|
179
|
+
const [row] = serializeLineItems([next])
|
|
180
|
+
expect(row).not.toHaveProperty('catalog')
|
|
181
|
+
expect(row).not.toHaveProperty('catalog_pending')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('tarjeta de la contraparte', () => {
|
|
186
|
+
it('etiqueta del campo, valores de catálogo «código · texto» y sin vacíos ni claves crudas', () => {
|
|
187
|
+
const meta = fieldDisplayMeta([
|
|
188
|
+
[{ key: 'tax_id', label: 'customers.field.tax_id' }, { key: 'email', label: 'Correo' }],
|
|
189
|
+
[{ key: 'fiscal_data.uso_cfdi', label: 'Uso del CFDI', options: [{ value: 'G03', label: 'Gastos en general' }] }],
|
|
190
|
+
], (s) => (s === 'customers.field.tax_id' ? 'RFC' : s))
|
|
191
|
+
const rows = partySummaryRows(
|
|
192
|
+
{ tax_id: 'TBA850312KJ9', email: ' ', fiscal_data: { uso_cfdi: 'G03', regimen: '601' } },
|
|
193
|
+
['tax_id', 'email'],
|
|
194
|
+
{},
|
|
195
|
+
6,
|
|
196
|
+
meta,
|
|
197
|
+
)
|
|
198
|
+
expect(rows).toEqual([
|
|
199
|
+
{ key: 'tax_id', label: 'RFC', value: 'TBA850312KJ9' },
|
|
200
|
+
{ key: 'fiscal_data.uso_cfdi', label: 'Uso del CFDI', value: 'G03 · Gastos en general' },
|
|
201
|
+
{ key: 'fiscal_data.regimen', label: 'Regimen', value: '601' },
|
|
202
|
+
])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('formatOptionValue: la etiqueta que ya trae el código va tal cual; sin opción, el valor', () => {
|
|
206
|
+
expect(formatOptionValue('G03', [{ value: 'G03', label: 'G03 · Gastos en general' }])).toBe('G03 · Gastos en general')
|
|
207
|
+
expect(formatOptionValue('active', [{ value: 'active', label: 'Activo' }])).toBe('Activo')
|
|
208
|
+
expect(formatOptionValue('X', [])).toBe('X')
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// ---- DocumentEditor: el puente con el host ----------------------------------
|
|
213
|
+
|
|
214
|
+
const invoice: DocumentFormType = {
|
|
215
|
+
key: 'invoice',
|
|
216
|
+
label: 'Factura',
|
|
217
|
+
value: 'I',
|
|
218
|
+
layout: 'editor',
|
|
219
|
+
party: { field: 'customer_id', model: 'customers.Customer', summary: ['tax_id', 'email'] },
|
|
220
|
+
fields: [{ key: 'customer_id', label: 'Cliente', type: 'text', required: true }],
|
|
221
|
+
lines: { columns: ['tax'] },
|
|
222
|
+
submit_label: 'Guardar factura',
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function makeApi(routes: Record<string, (url: string, cfg?: any) => unknown>) {
|
|
226
|
+
const get = vi.fn(async (url: string, cfg?: any) => {
|
|
227
|
+
for (const [prefix, fn] of Object.entries(routes)) if (url === prefix || url.startsWith(prefix + '?')) return { data: fn(url, cfg) }
|
|
228
|
+
if (url.startsWith('/metadata')) throw Object.assign(new Error('404'), { response: { status: 404 } })
|
|
229
|
+
return { data: { data: [] } }
|
|
230
|
+
})
|
|
231
|
+
return { get, post: vi.fn(), put: vi.fn(), delete: vi.fn() } as unknown as ApiClient & { get: typeof get }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
describe('DocumentEditor — crear/editar el producto del renglón por el host', () => {
|
|
235
|
+
it('crear: dispara metacore:create-record prellenado y llena el renglón con el registro leído del catálogo', async () => {
|
|
236
|
+
const created = { id: 'p9', name: 'Válvula nueva', sku: 'VAL-N', unit_price: 120, unit_of_measure: 'unit', is_active: true }
|
|
237
|
+
const api = makeApi({
|
|
238
|
+
'/data/products.Product': () => ({ data: [created] }),
|
|
239
|
+
'/metadata/modal/products.Product': () => ({ data: { fields: [{ key: 'unit_of_measure', label: 'Unidad', options: [{ value: 'unit', label: 'pza' }] }] } }),
|
|
240
|
+
})
|
|
241
|
+
const events: any[] = []
|
|
242
|
+
const onEvt = (e: Event) => events.push((e as CustomEvent).detail)
|
|
243
|
+
window.addEventListener('metacore:create-record', onEvt)
|
|
244
|
+
render(
|
|
245
|
+
<ApiProvider client={api} batch={false}>
|
|
246
|
+
<DocumentEditor model="customers.Invoice" forms={{ types: [invoice] }} type={invoice} onCancel={() => {}} currency="MXN" defaultTaxRate={0.16} />
|
|
247
|
+
</ApiProvider>,
|
|
248
|
+
)
|
|
249
|
+
await typeAndCreate(screen.getByRole('combobox', { name: 'Buscar producto' }), 'Válvula nueva')
|
|
250
|
+
window.removeEventListener('metacore:create-record', onEvt)
|
|
251
|
+
expect(events).toHaveLength(1)
|
|
252
|
+
expect(events[0]).toMatchObject({ model: 'products.Product', defaults: { name: 'Válvula nueva' } })
|
|
253
|
+
await act(async () => events[0].onCreated({ id: 'p9' }))
|
|
254
|
+
const row = await waitFor(() => {
|
|
255
|
+
const r = document.querySelector('[data-slot="editor-lines"] tr[data-line-key]') as HTMLElement | null
|
|
256
|
+
if (!r) throw new Error('sin renglón')
|
|
257
|
+
return r
|
|
258
|
+
})
|
|
259
|
+
expect((within(row).getByRole('textbox', { name: 'Descripción' }) as HTMLInputElement).value).toBe('Válvula nueva')
|
|
260
|
+
expect((within(row).getByRole('textbox', { name: 'Precio' }) as HTMLInputElement).value).toBe('120')
|
|
261
|
+
await waitFor(() => expect(row.textContent).toContain('VAL-N · pza'))
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('editar: el lápiz dispara metacore:edit-record con el id del producto del renglón', async () => {
|
|
265
|
+
const rec = { id: 'p1', name: 'Llanta', sku: 'LL', unit_price: 1500, is_active: true }
|
|
266
|
+
const api = makeApi({ '/data/products.Product': () => ({ data: [rec] }) })
|
|
267
|
+
render(
|
|
268
|
+
<ApiProvider client={api} batch={false}>
|
|
269
|
+
<DocumentEditor model="customers.Invoice" forms={{ types: [invoice] }} type={invoice} onCancel={() => {}} currency="MXN" defaultTaxRate={0.16} />
|
|
270
|
+
</ApiProvider>,
|
|
271
|
+
)
|
|
272
|
+
const input = screen.getByRole('combobox', { name: 'Buscar producto' })
|
|
273
|
+
input.focus()
|
|
274
|
+
fireEvent.change(input, { target: { value: 'Llan' } })
|
|
275
|
+
await waitFor(() => expect(screen.getAllByRole('option').length).toBeGreaterThan(0), { timeout: 2000 })
|
|
276
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
277
|
+
const events: any[] = []
|
|
278
|
+
const onEvt = (e: Event) => events.push((e as CustomEvent).detail)
|
|
279
|
+
window.addEventListener('metacore:edit-record', onEvt)
|
|
280
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Editar producto' }))
|
|
281
|
+
window.removeEventListener('metacore:edit-record', onEvt)
|
|
282
|
+
expect(events[0]).toMatchObject({ model: 'products.Product', recordId: 'p1' })
|
|
283
|
+
rec.unit_price = 1999
|
|
284
|
+
rec.name = 'Llanta XL'
|
|
285
|
+
await act(async () => events[0].onSaved(rec))
|
|
286
|
+
await waitFor(() => expect((screen.getByRole('textbox', { name: 'Precio' }) as HTMLInputElement).value).toBe('1999'))
|
|
287
|
+
expect((screen.getByRole('textbox', { name: 'Descripción' }) as HTMLInputElement).value).toBe('Llanta XL')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('tarjeta: si la clave con módulo no resuelve, usa la corta; etiquetas y catálogos legibles', async () => {
|
|
291
|
+
const api = makeApi({
|
|
292
|
+
'/data/customers.Customer': () => ({ data: [{ id: 'c1', name: 'ACME', tax_id: 'AAA010101AAA', email: '', fiscal_data: { uso_cfdi: 'G03' } }] }),
|
|
293
|
+
'/metadata/modal/Customer': () => ({ data: { fields: [{ key: 'tax_id', label: 'RFC' }, { key: 'fiscal_data.uso_cfdi', label: 'Uso del CFDI', options: [{ value: 'G03', label: 'Gastos en general' }] }] } }),
|
|
294
|
+
})
|
|
295
|
+
render(
|
|
296
|
+
<ApiProvider client={api} batch={false}>
|
|
297
|
+
<DocumentEditor model="customers.Invoice" forms={{ types: [invoice] }} type={invoice} onCancel={() => {}} currency="MXN" />
|
|
298
|
+
</ApiProvider>,
|
|
299
|
+
)
|
|
300
|
+
fireEvent.change(document.getElementById('customer_id')!, { target: { value: 'c1' } })
|
|
301
|
+
const card = await waitFor(() => {
|
|
302
|
+
const c = document.querySelector('[data-slot="party-card"]') as HTMLElement | null
|
|
303
|
+
if (!c || !c.textContent?.includes('RFC') || !c.textContent.includes('Gastos')) throw new Error('tarjeta sin etiquetas')
|
|
304
|
+
return c
|
|
305
|
+
})
|
|
306
|
+
expect(card.textContent).toContain('G03 · Gastos en general')
|
|
307
|
+
expect(card.textContent).not.toContain('tax_id')
|
|
308
|
+
expect(card.textContent).not.toContain('uso_cfdi')
|
|
309
|
+
// El correo vacío no aparece.
|
|
310
|
+
expect(within(card).queryByText(/email|Correo/i)).toBeNull()
|
|
311
|
+
})
|
|
312
|
+
})
|
|
@@ -178,12 +178,68 @@ export function partyDefaults(
|
|
|
178
178
|
return out
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
/**
|
|
181
|
+
/** Etiqueta y opciones de catálogo de un campo (de la metadata del modelo o del propio tipo). */
|
|
182
|
+
export interface FieldDisplayMeta {
|
|
183
|
+
label?: string
|
|
184
|
+
options?: ReadonlyArray<{ value: unknown; label?: unknown }>
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Metadata de pantalla por clave a partir de listas de campos/columnas
|
|
189
|
+
* (`/metadata/modal` → fields, `/metadata/table` → columns, campos del tipo).
|
|
190
|
+
* La primera lista que trae etiqueta u opciones para una clave gana.
|
|
191
|
+
*/
|
|
192
|
+
export function fieldDisplayMeta(
|
|
193
|
+
lists: ReadonlyArray<ReadonlyArray<unknown> | null | undefined>,
|
|
194
|
+
translate: (s: string) => string = (s) => s,
|
|
195
|
+
): Record<string, FieldDisplayMeta> {
|
|
196
|
+
const out: Record<string, FieldDisplayMeta> = {}
|
|
197
|
+
for (const list of lists) {
|
|
198
|
+
for (const raw of list ?? []) {
|
|
199
|
+
const f = raw as { key?: unknown; name?: unknown; label?: unknown; options?: unknown }
|
|
200
|
+
const key = typeof f?.key === 'string' ? f.key : typeof f?.name === 'string' ? f.name : undefined
|
|
201
|
+
if (!key) continue
|
|
202
|
+
const cur = (out[key] ??= {})
|
|
203
|
+
if (!cur.label && typeof f.label === 'string' && f.label) cur.label = translate(f.label)
|
|
204
|
+
if (!cur.options && Array.isArray(f.options) && f.options.length > 0) {
|
|
205
|
+
cur.options = f.options.map((o: any) => ({ value: o?.value, label: typeof o?.label === 'string' ? translate(o.label) : o?.label }))
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** `tax_id` → «Tax id»: último recurso cuando ningún metadata etiqueta la columna. */
|
|
213
|
+
export function humanizeKey(key: string): string {
|
|
214
|
+
const base = key.replace(EXTENSION_PREFIX, '').replace(/[_.]+/g, ' ').trim()
|
|
215
|
+
return base ? base.charAt(0).toUpperCase() + base.slice(1) : key
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Valor de catálogo legible: `G03` con la opción «Gastos en general» →
|
|
220
|
+
* «G03 · Gastos en general» (si la etiqueta ya trae el código, tal cual).
|
|
221
|
+
*/
|
|
222
|
+
export function formatOptionValue(value: unknown, options?: FieldDisplayMeta['options']): string {
|
|
223
|
+
const raw = String(value)
|
|
224
|
+
const opt = options?.find((o) => String(o.value) === raw)
|
|
225
|
+
const label = opt && typeof opt.label === 'string' ? opt.label.trim() : ''
|
|
226
|
+
if (!label || label === raw) return raw
|
|
227
|
+
if (label.includes(raw)) return label
|
|
228
|
+
const codeLike = /^[A-Z0-9]{1,5}$/.test(raw) && /\d/.test(raw)
|
|
229
|
+
return codeLike ? `${raw} · ${label}` : label
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Filas de la tarjeta de contraparte: resumen declarado + extensiones (máximo
|
|
234
|
+
* `limit`). Etiqueta del campo (metadata / labels declarados, ya traducidos),
|
|
235
|
+
* valores de catálogo con su texto y nunca claves crudas ni vacíos.
|
|
236
|
+
*/
|
|
182
237
|
export function partySummaryRows(
|
|
183
238
|
party: Record<string, any> | null,
|
|
184
239
|
summary: readonly string[] = [],
|
|
185
240
|
labels: Record<string, string> = {},
|
|
186
241
|
limit = 6,
|
|
242
|
+
meta: Record<string, FieldDisplayMeta> = {},
|
|
187
243
|
): Array<{ key: string; label: string; value: string }> {
|
|
188
244
|
if (!party) return []
|
|
189
245
|
const keys = new Set<string>(summary)
|
|
@@ -193,7 +249,9 @@ export function partySummaryRows(
|
|
|
193
249
|
for (const key of keys) {
|
|
194
250
|
const v = readPath(party, key)
|
|
195
251
|
if (!hasValue(v) || typeof v === 'object') continue
|
|
196
|
-
|
|
252
|
+
if (typeof v === 'string' && !v.trim()) continue
|
|
253
|
+
const m = meta[key]
|
|
254
|
+
rows.push({ key, label: labels[key] ?? m?.label ?? humanizeKey(key), value: formatOptionValue(v, m?.options) })
|
|
197
255
|
}
|
|
198
256
|
return rows.slice(0, limit)
|
|
199
257
|
}
|
|
@@ -29,13 +29,17 @@ import { FormErrorBanner } from './feedback'
|
|
|
29
29
|
import { DocumentLinesGrid, type DocumentLinesGridProps, type LineItemsColumn } from './line-items-editor'
|
|
30
30
|
import { computeTotals, serializeLineItems, taxBreakdown, type LineItem } from './line-items'
|
|
31
31
|
import { roundMoney, toAmount, useFormatter } from './format'
|
|
32
|
-
import { createCatalogProductSearch } from './catalog-product-search'
|
|
32
|
+
import { catalogRecordToProduct, createCatalogProductSearch } from './catalog-product-search'
|
|
33
|
+
import type { ProductResult } from './product-search'
|
|
34
|
+
import { requestRecordCreate, requestRecordEdit, withSearchPrefill } from '../record-picker-actions'
|
|
33
35
|
import {
|
|
34
36
|
allocationAmountField,
|
|
35
37
|
allocationIssueMessages,
|
|
36
38
|
allocationPayload,
|
|
37
39
|
creditStatus,
|
|
38
40
|
editorLinesConfig,
|
|
41
|
+
fieldDisplayMeta,
|
|
42
|
+
type FieldDisplayMeta,
|
|
39
43
|
linesFromSource,
|
|
40
44
|
localIssues,
|
|
41
45
|
sourceLoadSummary,
|
|
@@ -91,8 +95,13 @@ export interface DocumentEditorProps {
|
|
|
91
95
|
onSaved?: (record?: unknown) => void
|
|
92
96
|
onCancel: () => void
|
|
93
97
|
searchProducts?: DocumentLinesGridProps['search']
|
|
94
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Modelo de catálogo: buscador por defecto y alta/edición del producto
|
|
100
|
+
* desde la celda del renglón. Default `products.Product`.
|
|
101
|
+
*/
|
|
95
102
|
productModel?: string
|
|
103
|
+
/** Columna del modelo de catálogo que recibe lo buscado al crear. Default `name`. */
|
|
104
|
+
productLabelField?: string
|
|
96
105
|
defaultTaxRate?: number
|
|
97
106
|
currency?: string
|
|
98
107
|
/** Hoy (días de atraso del reparto; inyectable en tests). */
|
|
@@ -118,6 +127,7 @@ export function DocumentEditor({
|
|
|
118
127
|
onCancel,
|
|
119
128
|
searchProducts,
|
|
120
129
|
productModel,
|
|
130
|
+
productLabelField = 'name',
|
|
121
131
|
defaultTaxRate,
|
|
122
132
|
currency,
|
|
123
133
|
today,
|
|
@@ -145,7 +155,8 @@ export function DocumentEditor({
|
|
|
145
155
|
const [linesFromSourceDoc, setLinesFromSourceDoc] = useState(false)
|
|
146
156
|
const [extFields, setExtFields] = useState<ActionFieldDef[]>([])
|
|
147
157
|
const [party, setParty] = useState<Record<string, any> | null>(null)
|
|
148
|
-
const [
|
|
158
|
+
const [partyFieldLists, setPartyFieldLists] = useState<unknown[][]>([])
|
|
159
|
+
const [catalogMeta, setCatalogMeta] = useState<Record<string, FieldDisplayMeta> | null>(null)
|
|
149
160
|
// Etiquetas de los selectores que el editor llena por código (contraparte y
|
|
150
161
|
// documento origen de «Cargar desde…»): sin ellas el selector cerrado pinta el UUID.
|
|
151
162
|
const [seeds, setSeeds] = useState<Record<string, SeedLabel>>({})
|
|
@@ -254,25 +265,93 @@ export function DocumentEditor({
|
|
|
254
265
|
}
|
|
255
266
|
}, [api, type.party, partyId])
|
|
256
267
|
|
|
257
|
-
// Etiquetas de la tarjeta (RFC, régimen…)
|
|
258
|
-
//
|
|
268
|
+
// Etiquetas de la tarjeta (RFC, régimen…) y texto de sus catálogos: la
|
|
269
|
+
// metadata del modelo de la contraparte (formulario, con extensiones, y
|
|
270
|
+
// tabla); sin ellas se veía la columna cruda (`tax_id`).
|
|
259
271
|
const partyModel = type.party?.model
|
|
260
272
|
useEffect(() => {
|
|
261
273
|
if (!partyModel) return
|
|
262
274
|
let cancelled = false
|
|
263
|
-
api.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const out: Record<string, string> = {}
|
|
267
|
-
for (const f of fields) if (typeof f?.key === 'string' && typeof f?.label === 'string' && f.label) out[f.key] = f.label
|
|
268
|
-
if (!cancelled) setPartyLabels(out)
|
|
269
|
-
})
|
|
270
|
-
.catch(() => {})
|
|
275
|
+
void loadModelFieldLists(api, partyModel).then((lists) => {
|
|
276
|
+
if (!cancelled) setPartyFieldLists(lists)
|
|
277
|
+
})
|
|
271
278
|
return () => {
|
|
272
279
|
cancelled = true
|
|
273
280
|
}
|
|
274
281
|
}, [api, partyModel])
|
|
275
282
|
|
|
283
|
+
// Modelo de catálogo del renglón: alta/edición desde la celda y etiquetas
|
|
284
|
+
// de sus catálogos (unidad «unit» → «Pieza»).
|
|
285
|
+
const catalogModel = productModel ?? 'products.Product'
|
|
286
|
+
// Perezoso: solo cuando ya hay un renglón con producto (no compite con la
|
|
287
|
+
// carga inicial del editor).
|
|
288
|
+
const hasCatalogLine = lines.some((l) => l.kind === 'item' && !!l.product_id)
|
|
289
|
+
const wantsCatalogMeta = !!lineCfg && !isAllocation && hasCatalogLine
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
if (!wantsCatalogMeta) return
|
|
292
|
+
let cancelled = false
|
|
293
|
+
void loadModelFieldLists(api, catalogModel).then((lists) => {
|
|
294
|
+
if (!cancelled) setCatalogMeta(fieldDisplayMeta(lists, tl))
|
|
295
|
+
})
|
|
296
|
+
return () => {
|
|
297
|
+
cancelled = true
|
|
298
|
+
}
|
|
299
|
+
}, [api, catalogModel, tl, wantsCatalogMeta])
|
|
300
|
+
|
|
301
|
+
/** Lee el producto guardado con la misma proyección del buscador (catalogRecordToProduct). */
|
|
302
|
+
const loadCatalogProduct = useCallback(
|
|
303
|
+
async (id: string, saved?: Record<string, any>): Promise<ProductResult | null> => {
|
|
304
|
+
try {
|
|
305
|
+
const res: any = await api.get(`/data/${catalogModel}`, { params: { f_id: `eq:${id}`, per_page: 1 } })
|
|
306
|
+
const rows = res?.data?.data
|
|
307
|
+
const row = Array.isArray(rows) ? rows[0] : null
|
|
308
|
+
if (row) return catalogRecordToProduct({ ...(saved ?? {}), ...row }, taxRate)
|
|
309
|
+
} catch {
|
|
310
|
+
// Sin lectura, lo que devolvió el guardado.
|
|
311
|
+
}
|
|
312
|
+
return saved && saved.id != null ? catalogRecordToProduct(saved, taxRate) : null
|
|
313
|
+
},
|
|
314
|
+
[api, catalogModel, taxRate],
|
|
315
|
+
)
|
|
316
|
+
const createProduct = useCallback(
|
|
317
|
+
(query: string) =>
|
|
318
|
+
new Promise<ProductResult | null>((resolve) => {
|
|
319
|
+
requestRecordCreate({
|
|
320
|
+
model: catalogModel,
|
|
321
|
+
defaults: withSearchPrefill(query, productLabelField),
|
|
322
|
+
onCreated: (rec: any) => {
|
|
323
|
+
if (rec?.id == null) return resolve(null)
|
|
324
|
+
void loadCatalogProduct(String(rec.id), rec).then(resolve)
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
}),
|
|
328
|
+
[catalogModel, productLabelField, loadCatalogProduct],
|
|
329
|
+
)
|
|
330
|
+
const editProduct = useCallback(
|
|
331
|
+
(line: LineItem) =>
|
|
332
|
+
new Promise<ProductResult | null>((resolve) => {
|
|
333
|
+
const id = line.catalog?.product_ref ?? line.product_id
|
|
334
|
+
if (!id) return resolve(null)
|
|
335
|
+
requestRecordEdit({
|
|
336
|
+
model: catalogModel,
|
|
337
|
+
recordId: id,
|
|
338
|
+
onSaved: (rec: any) => void loadCatalogProduct(id, rec && typeof rec === 'object' ? { id, ...rec } : undefined).then(resolve),
|
|
339
|
+
})
|
|
340
|
+
}),
|
|
341
|
+
[catalogModel, loadCatalogProduct],
|
|
342
|
+
)
|
|
343
|
+
/** Unidad visible: la opción del catálogo; sin opciones declaradas, el valor tal cual. */
|
|
344
|
+
const unitLabel = useCallback(
|
|
345
|
+
(u: string) => {
|
|
346
|
+
if (!catalogMeta) return undefined
|
|
347
|
+
const opts = catalogMeta.unit_of_measure?.options ?? catalogMeta.unit?.options
|
|
348
|
+
if (!opts) return u
|
|
349
|
+
const o = opts.find((x) => String(x.value) === u)
|
|
350
|
+
return typeof o?.label === 'string' && o.label && o.label !== u ? o.label : undefined
|
|
351
|
+
},
|
|
352
|
+
[catalogMeta],
|
|
353
|
+
)
|
|
354
|
+
|
|
276
355
|
// Cobro: documentos abiertos de la contraparte.
|
|
277
356
|
const openCfg = lineCfg?.open_documents
|
|
278
357
|
const allocPartyId = openCfg ? header[openCfg.party_field] ?? partyId : undefined
|
|
@@ -595,10 +674,10 @@ export function DocumentEditor({
|
|
|
595
674
|
</FieldCell>
|
|
596
675
|
) : null
|
|
597
676
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
})
|
|
677
|
+
// Etiquetas y catálogos: la metadata de la contraparte y, para las
|
|
678
|
+
// extensiones compartidas (`fiscal_data.uso_cfdi`), los campos del propio documento.
|
|
679
|
+
const summaryMeta = useMemo(() => fieldDisplayMeta([...partyFieldLists, allFields], tl), [partyFieldLists, allFields, tl])
|
|
680
|
+
const summaryRows = partySummaryRows(party, type.party?.summary, {}, 6, summaryMeta)
|
|
602
681
|
const advancedSummary = groups.advanced
|
|
603
682
|
.filter(visible)
|
|
604
683
|
.map((f) => {
|
|
@@ -782,6 +861,9 @@ export function DocumentEditor({
|
|
|
782
861
|
search={search}
|
|
783
862
|
currency={currency}
|
|
784
863
|
showTotals={false}
|
|
864
|
+
onCreateProduct={kind === 'credit' ? undefined : createProduct}
|
|
865
|
+
onEditProduct={editProduct}
|
|
866
|
+
unitLabel={unitLabel}
|
|
785
867
|
/>
|
|
786
868
|
)}
|
|
787
869
|
{!fullscreen && totalsPanel}
|
|
@@ -954,3 +1036,36 @@ function SourcePicker({
|
|
|
954
1036
|
</div>
|
|
955
1037
|
)
|
|
956
1038
|
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Campos y columnas de un modelo para etiquetar sus valores: formulario
|
|
1042
|
+
* (`/metadata/modal`, con extensiones) y tabla (`/metadata/table`). Si la
|
|
1043
|
+
* clave con módulo (`customers.Customer`) no resuelve en el host, prueba la
|
|
1044
|
+
* corta (`Customer`), como el puente de alta del host. Nunca lanza.
|
|
1045
|
+
*/
|
|
1046
|
+
async function loadModelFieldLists(api: { get: (url: string, cfg?: any) => Promise<any> }, model: string): Promise<unknown[][]> {
|
|
1047
|
+
const short = model.includes('.') ? model.slice(model.lastIndexOf('.') + 1) : undefined
|
|
1048
|
+
// `undefined` = la petición falló (el host no conoce esa clave); `[]` = respondió sin campos.
|
|
1049
|
+
const read = async (url: string, key: 'fields' | 'columns'): Promise<unknown[] | undefined> => {
|
|
1050
|
+
try {
|
|
1051
|
+
const res = await api.get(url)
|
|
1052
|
+
const list = res?.data?.data?.[key] ?? res?.data?.[key]
|
|
1053
|
+
return Array.isArray(list) ? list : []
|
|
1054
|
+
} catch {
|
|
1055
|
+
return undefined
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
// En serie y solo lo necesario (una lectura en el caso normal): el
|
|
1059
|
+
// formulario; la clave corta solo si la larga falló; la tabla solo si
|
|
1060
|
+
// ningún formulario respondió con campos.
|
|
1061
|
+
const candidates = short ? [model, short] : [model]
|
|
1062
|
+
for (const kind of ['modal', 'table'] as const) {
|
|
1063
|
+
for (const m of candidates) {
|
|
1064
|
+
const list = await read(`/metadata/${kind}/${m}`, kind === 'modal' ? 'fields' : 'columns')
|
|
1065
|
+
if (list === undefined) continue
|
|
1066
|
+
if (list.length > 0) return [list]
|
|
1067
|
+
break
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return []
|
|
1071
|
+
}
|