@asteby/metacore-runtime-react 49.6.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 +12 -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/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +22 -4
- package/dist/dynamic-select-field.d.ts +2 -0
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +42 -15
- 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/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +4 -0
- 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/dist/use-option-ids.d.ts +34 -0
- package/dist/use-option-ids.d.ts.map +1 -0
- package/dist/use-option-ids.js +138 -0
- package/dist/use-options-resolver.d.ts +4 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +10 -1
- package/package.json +1 -1
- package/src/__tests__/document-editor-inline-selects.test.tsx +11 -4
- package/src/__tests__/edit-field-polish.test.tsx +6 -3
- package/src/__tests__/line-row-create-edit.test.tsx +312 -0
- package/src/__tests__/option-ids.test.tsx +181 -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/dynamic-multi-select-field.tsx +20 -8
- package/src/dynamic-select-field.tsx +51 -17
- package/src/field-grid.tsx +5 -4
- package/src/index.ts +8 -0
- package/src/query-batch.ts +3 -0
- package/src/record-picker.tsx +16 -3
- package/src/use-option-ids.ts +168 -0
- package/src/use-options-resolver.ts +11 -1
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Helpers del editor único de renglones (DocumentLinesGrid / LineItemsEditor).
|
|
2
2
|
// Puros: el componente solo pinta. `serializeLineItems` sigue siendo la puerta al backend.
|
|
3
3
|
import { toAmount } from './format'
|
|
4
|
-
import { makeLine, type LineItem } from './line-items'
|
|
4
|
+
import { CATALOG_LINE_FIELDS, makeLine, type CatalogLineField, type LineCatalogSnapshot, type LineItem } from './line-items'
|
|
5
5
|
import { availableStock, type ProductResult, type ProductVariant } from './product-search'
|
|
6
6
|
|
|
7
7
|
export type PriceSource = 'sale' | 'cost' | 'origin'
|
|
@@ -33,21 +33,93 @@ export function applyProductToLine(
|
|
|
33
33
|
const catalog = source === 'cost' ? (cost ?? list) : list
|
|
34
34
|
// Nunca cantidad 0: un renglón vacío (o con la celda borrada) entra con 1.
|
|
35
35
|
const qty = opts.quantity ?? toAmount(line.quantity)
|
|
36
|
+
const values = catalogLineValues(line, product, variant, source)
|
|
37
|
+
const { catalog_pending: _pending, ...rest } = line
|
|
36
38
|
return {
|
|
37
|
-
...
|
|
39
|
+
...rest,
|
|
38
40
|
kind: 'item',
|
|
39
41
|
product_id: variant?.id ?? product.id,
|
|
40
|
-
|
|
41
|
-
description: variant ? `${product.name} · ${variant.label}` : product.name,
|
|
42
|
+
...values,
|
|
42
43
|
quantity: qty !== 0 ? qty : 1,
|
|
43
|
-
unit_price: priceFromProduct(product, variant, source),
|
|
44
44
|
catalog_price: catalog,
|
|
45
45
|
cost,
|
|
46
|
-
tax_rate: product.tax_rate ?? line.tax_rate ?? 0,
|
|
47
|
-
unit: product.unit ?? line.unit,
|
|
48
46
|
extensions: product.extensions ? { ...(line.extensions ?? {}), ...product.extensions } : line.extensions,
|
|
49
47
|
available: availableStock(src, opts.warehouseId) ?? availableStock(product, opts.warehouseId),
|
|
48
|
+
catalog: { ...values, product_ref: product.id, variant },
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Lo que el producto escribe en los campos editables del renglón. Es EL mapeo
|
|
54
|
+
* producto → renglón: lo usan elegir, crear y editar desde la celda.
|
|
55
|
+
*/
|
|
56
|
+
function catalogLineValues(
|
|
57
|
+
line: LineItem,
|
|
58
|
+
product: ProductResult,
|
|
59
|
+
variant: ProductVariant | undefined,
|
|
60
|
+
source: PriceSource,
|
|
61
|
+
): Pick<LineItem, CatalogLineField> {
|
|
62
|
+
return {
|
|
63
|
+
description: variant ? `${product.name} · ${variant.label}` : product.name,
|
|
64
|
+
sku: variant?.sku ?? product.sku,
|
|
65
|
+
unit_price: priceFromProduct(product, variant, source),
|
|
66
|
+
tax_rate: product.tax_rate ?? line.tax_rate ?? 0,
|
|
67
|
+
unit: product.unit ?? line.unit,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sameValue = (a: unknown, b: unknown): boolean =>
|
|
72
|
+
typeof a === 'number' || typeof b === 'number' ? toAmount(a) === toAmount(b) : (a ?? '') === (b ?? '')
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Campos que el usuario cambió a mano respecto de lo que puso el catálogo
|
|
76
|
+
* («dirty» por campo). Sin foto (renglón libre, o cargado de un documento
|
|
77
|
+
* origen con sus propios precios) todo cuenta como capturado: nada se pisa.
|
|
78
|
+
*/
|
|
79
|
+
export function lineOverrides(line: LineItem): Set<CatalogLineField> {
|
|
80
|
+
const snap = line.catalog
|
|
81
|
+
if (!snap) return new Set(CATALOG_LINE_FIELDS)
|
|
82
|
+
return new Set(CATALOG_LINE_FIELDS.filter((f) => !sameValue(line[f], snap[f as keyof LineCatalogSnapshot])))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* El producto del renglón cambió en el catálogo (se editó desde la celda):
|
|
87
|
+
* actualiza SOLO los campos que siguen igual a lo que el catálogo había puesto;
|
|
88
|
+
* lo que el usuario sobrescribió se respeta y el valor nuevo queda en
|
|
89
|
+
* `catalog_pending` para ofrecer «aplicar». Nunca toca la cantidad, el tope de
|
|
90
|
+
* lo pendiente ni el vínculo con el documento origen. Siempre refresca lo que
|
|
91
|
+
* no se captura en el renglón (precio de catálogo, costo, extensiones, existencia).
|
|
92
|
+
*/
|
|
93
|
+
export function refreshLineFromProduct(
|
|
94
|
+
line: LineItem,
|
|
95
|
+
product: ProductResult,
|
|
96
|
+
opts: { priceSource?: PriceSource; warehouseId?: string } = {},
|
|
97
|
+
): LineItem {
|
|
98
|
+
const source = opts.priceSource ?? 'sale'
|
|
99
|
+
const variant = line.catalog?.variant
|
|
100
|
+
const fresh = applyProductToLine(line, product, variant, { ...opts, quantity: toAmount(line.quantity) })
|
|
101
|
+
const overrides = lineOverrides(line)
|
|
102
|
+
const next: LineItem = { ...fresh, product_id: line.product_id ?? fresh.product_id, quantity: line.quantity, max_quantity: line.max_quantity, source_line_id: line.source_line_id }
|
|
103
|
+
const pending: Partial<Pick<LineItem, CatalogLineField>> = {}
|
|
104
|
+
const values = catalogLineValues(line, product, variant, source)
|
|
105
|
+
for (const f of CATALOG_LINE_FIELDS) {
|
|
106
|
+
if (!overrides.has(f)) continue
|
|
107
|
+
// Respeta lo capturado; si el catálogo trae otro valor, se ofrece aplicarlo.
|
|
108
|
+
;(next as any)[f] = line[f]
|
|
109
|
+
if (!sameValue(values[f], line[f])) (pending as any)[f] = values[f]
|
|
50
110
|
}
|
|
111
|
+
// La foto avanza a los valores nuevos del catálogo: el override sigue siéndolo.
|
|
112
|
+
next.catalog = { ...values, product_ref: line.catalog?.product_ref ?? product.id, variant }
|
|
113
|
+
if (Object.keys(pending).length > 0) next.catalog_pending = pending
|
|
114
|
+
else delete next.catalog_pending
|
|
115
|
+
return next
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** «Aplicar» el aviso del catálogo: los valores pendientes entran al renglón. */
|
|
119
|
+
export function applyCatalogPending(line: LineItem): LineItem {
|
|
120
|
+
if (!line.catalog_pending) return line
|
|
121
|
+
const { catalog_pending: pending, ...rest } = line
|
|
122
|
+
return { ...rest, ...pending }
|
|
51
123
|
}
|
|
52
124
|
|
|
53
125
|
/**
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
// Controlado: `value`/`onChange` con `LineItem[]`. Guardar = `serializeLineItems`.
|
|
4
4
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
|
5
5
|
import { useTranslation } from 'react-i18next'
|
|
6
|
-
import { Heading, Minus, Plus, StickyNote, Trash2 } from 'lucide-react'
|
|
6
|
+
import { Heading, Minus, Pencil, Plus, StickyNote, Trash2 } from 'lucide-react'
|
|
7
7
|
import { Button, Input } from '@asteby/metacore-ui'
|
|
8
8
|
import { useCan } from '../permissions-context'
|
|
9
9
|
import { useFormatter } from './format'
|
|
10
10
|
import {
|
|
11
11
|
addProductLine,
|
|
12
|
+
applyCatalogPending,
|
|
12
13
|
applyProductToLine,
|
|
14
|
+
refreshLineFromProduct,
|
|
13
15
|
lineGridKeyCommand,
|
|
14
16
|
type DocumentLinesMode,
|
|
15
17
|
type PriceSource,
|
|
@@ -73,6 +75,26 @@ export interface DocumentLinesGridProps {
|
|
|
73
75
|
* ya muestra sus propios totales (DocumentEditor) lo apaga.
|
|
74
76
|
*/
|
|
75
77
|
showTotals?: boolean
|
|
78
|
+
/**
|
|
79
|
+
* Alta del producto desde la celda («+» y «Crear producto «texto»»). Resuelve
|
|
80
|
+
* con el producto guardado (o nada si se canceló): el renglón se llena con
|
|
81
|
+
* el mismo mapeo que al elegir uno existente.
|
|
82
|
+
*/
|
|
83
|
+
onCreateProduct?: (query: string) => Promise<ProductResult | null | undefined>
|
|
84
|
+
/**
|
|
85
|
+
* Edición del producto del renglón (lápiz en la celda). Resuelve con el
|
|
86
|
+
* producto ya guardado: el renglón se actualiza salvo los campos que el
|
|
87
|
+
* usuario sobrescribió (ver `refreshLineFromProduct`).
|
|
88
|
+
*/
|
|
89
|
+
onEditProduct?: (line: LineItem) => Promise<ProductResult | null | undefined>
|
|
90
|
+
/** Nombre del modelo de catálogo para «Crear …» / «Editar …» (default «producto»). */
|
|
91
|
+
productLabel?: string
|
|
92
|
+
/**
|
|
93
|
+
* Etiqueta visible de la unidad (`unit` → «pza»). Si devuelve vacío, la
|
|
94
|
+
* unidad no se muestra bajo el SKU (nunca la clave cruda). Sin esta prop se
|
|
95
|
+
* muestra el valor tal cual.
|
|
96
|
+
*/
|
|
97
|
+
unitLabel?: (unit: string) => string | undefined
|
|
76
98
|
}
|
|
77
99
|
|
|
78
100
|
/** @deprecated Usa `DocumentLinesGridProps`. Es el mismo contrato. */
|
|
@@ -100,6 +122,10 @@ export function DocumentLinesGrid({
|
|
|
100
122
|
editPermission,
|
|
101
123
|
currency,
|
|
102
124
|
showTotals = true,
|
|
125
|
+
onCreateProduct,
|
|
126
|
+
onEditProduct,
|
|
127
|
+
productLabel,
|
|
128
|
+
unitLabel,
|
|
103
129
|
}: DocumentLinesGridProps) {
|
|
104
130
|
const { t } = useTranslation()
|
|
105
131
|
const can = useCan()
|
|
@@ -125,6 +151,12 @@ export function DocumentLinesGrid({
|
|
|
125
151
|
const canAdd = !locked && !fromSource
|
|
126
152
|
const [undo, setUndo] = useState<LineItem[] | null>(null)
|
|
127
153
|
|
|
154
|
+
// Crear/editar desde la celda resuelven DESPUÉS de un modal: se trabaja
|
|
155
|
+
// sobre los renglones de ese momento (no los del render que abrió el modal).
|
|
156
|
+
const latest = useRef(value)
|
|
157
|
+
latest.current = value
|
|
158
|
+
const entity = productLabel ?? t('lineItems.productEntity', { defaultValue: 'producto' })
|
|
159
|
+
|
|
128
160
|
const emit = (next: LineItem[]) => {
|
|
129
161
|
onChange(next)
|
|
130
162
|
const priced = discountMode === 'amount' ? next.map((l) => (l.kind === 'item' ? { ...l, discount_kind: 'amount' as const } : l)) : next
|
|
@@ -132,7 +164,7 @@ export function DocumentLinesGrid({
|
|
|
132
164
|
}
|
|
133
165
|
const patch = (idx: number, p: Partial<LineItem>) => {
|
|
134
166
|
if (discountMode === 'amount') p = { ...p, discount_kind: 'amount' }
|
|
135
|
-
emit(value.map((l, i) => (i === idx ? { ...l, ...p } : l)))
|
|
167
|
+
emit(value.map((l, i) => (i === idx ? withoutAnsweredPending({ ...l, ...p }, p) : l)))
|
|
136
168
|
}
|
|
137
169
|
const remove = (idx: number) => {
|
|
138
170
|
setUndo(value)
|
|
@@ -159,23 +191,54 @@ export function DocumentLinesGrid({
|
|
|
159
191
|
const blankLine = () => makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {})
|
|
160
192
|
|
|
161
193
|
/** Renglón vacío del final: el producto entra como renglón nuevo (o suma al mismo). */
|
|
162
|
-
const pickProduct = (product: ProductResult, variant?: ProductVariant) => {
|
|
163
|
-
const next = addProductLine(
|
|
194
|
+
const pickProduct = (product: ProductResult, variant?: ProductVariant, base: LineItem[] = value) => {
|
|
195
|
+
const next = addProductLine(base, product, variant, { priceSource, warehouseId, mergeSameProduct })
|
|
164
196
|
emit(next)
|
|
165
197
|
const id = variant?.id ?? product.id
|
|
166
|
-
const target = next.find((l) => l.kind === 'item' && l.product_id === id && !
|
|
198
|
+
const target = next.find((l) => l.kind === 'item' && l.product_id === id && !base.includes(l)) ?? next[next.length - 1]
|
|
167
199
|
if (target) setFocusQty(target.key)
|
|
168
200
|
}
|
|
169
201
|
|
|
170
202
|
/** Renglón libre existente: el producto elegido en su celda llena ESE renglón. */
|
|
171
|
-
const fillLine = (idx: number, product: ProductResult, variant?: ProductVariant) => {
|
|
172
|
-
const cur =
|
|
203
|
+
const fillLine = (idx: number, product: ProductResult, variant?: ProductVariant, base: LineItem[] = value) => {
|
|
204
|
+
const cur = base[idx]
|
|
173
205
|
if (!cur) return
|
|
174
206
|
const filled = applyProductToLine(cur, product, variant, { priceSource, warehouseId })
|
|
175
|
-
emit(
|
|
207
|
+
emit(base.map((l, i) => (i === idx ? (discountMode === 'amount' ? { ...filled, discount_kind: 'amount' as const } : filled) : l)))
|
|
176
208
|
setFocusQty(cur.key)
|
|
177
209
|
}
|
|
178
210
|
|
|
211
|
+
/**
|
|
212
|
+
* «Crear producto «texto»» / «+»: el alta del modelo de catálogo; al
|
|
213
|
+
* guardarse, el producto nuevo entra en ESE renglón (o en uno nuevo desde
|
|
214
|
+
* el renglón del final) y lo llena igual que al elegir uno existente.
|
|
215
|
+
*/
|
|
216
|
+
const createProduct = onCreateProduct
|
|
217
|
+
? async (query: string, lineKey?: string) => {
|
|
218
|
+
const product = await onCreateProduct(query)
|
|
219
|
+
if (!product) return
|
|
220
|
+
const base = latest.current
|
|
221
|
+
const idx = lineKey ? base.findIndex((l) => l.key === lineKey) : -1
|
|
222
|
+
if (idx >= 0 && !base[idx].product_id) fillLine(idx, product, undefined, base)
|
|
223
|
+
else pickProduct(product, undefined, base)
|
|
224
|
+
}
|
|
225
|
+
: undefined
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Lápiz: edita el producto del renglón; al guardar, el renglón toma los
|
|
229
|
+
* valores nuevos salvo lo que el usuario sobrescribió (queda el aviso).
|
|
230
|
+
*/
|
|
231
|
+
const editProduct = onEditProduct
|
|
232
|
+
? async (line: LineItem) => {
|
|
233
|
+
const product = await onEditProduct(line)
|
|
234
|
+
if (!product) return
|
|
235
|
+
const base = latest.current
|
|
236
|
+
emit(base.map((l) => (l.key === line.key ? refreshLineFromProduct(l, product, { priceSource, warehouseId }) : l)))
|
|
237
|
+
}
|
|
238
|
+
: undefined
|
|
239
|
+
|
|
240
|
+
const unitText = (u?: string) => (!u ? undefined : unitLabel ? unitLabel(u) || undefined : u)
|
|
241
|
+
|
|
179
242
|
/** Texto escrito en el renglón vacío sin elegir producto → renglón libre con esa descripción. */
|
|
180
243
|
const addFreeFromDraft = (description: string) => {
|
|
181
244
|
const line = { ...blankLine(), description }
|
|
@@ -296,21 +359,57 @@ export function DocumentLinesGrid({
|
|
|
296
359
|
ariaLabel={t('lineItems.description', { defaultValue: 'Descripción' })}
|
|
297
360
|
invalid={!!cell(i, 'description')}
|
|
298
361
|
dataCell="description"
|
|
362
|
+
onCreate={createProduct ? (q) => void createProduct(q, l.key) : undefined}
|
|
363
|
+
entityLabel={entity}
|
|
299
364
|
/>
|
|
300
365
|
) : (
|
|
301
|
-
<
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
366
|
+
<div className="relative" data-slot="line-description">
|
|
367
|
+
<Input
|
|
368
|
+
value={l.description}
|
|
369
|
+
disabled={locked}
|
|
370
|
+
className={INPUT + (editProduct && l.product_id && !locked ? ' pr-9' : '')}
|
|
371
|
+
data-cell="description"
|
|
372
|
+
aria-label={t('lineItems.description', { defaultValue: 'Descripción' })}
|
|
373
|
+
aria-invalid={!!cell(i, 'description')}
|
|
374
|
+
onChange={(e) => patch(i, { description: e.target.value })}
|
|
375
|
+
onKeyDown={(e) => descKeys(e, i, l)}
|
|
376
|
+
/>
|
|
377
|
+
{editProduct && l.product_id && !locked && (
|
|
378
|
+
<Button
|
|
379
|
+
type="button"
|
|
380
|
+
size="icon"
|
|
381
|
+
variant="ghost"
|
|
382
|
+
className={EDIT}
|
|
383
|
+
data-slot="line-edit-product"
|
|
384
|
+
aria-label={t('lineItems.editProduct', { defaultValue: 'Editar {{entity}}', entity })}
|
|
385
|
+
title={t('lineItems.editProduct', { defaultValue: 'Editar {{entity}}', entity })}
|
|
386
|
+
onClick={() => void editProduct(l)}
|
|
387
|
+
>
|
|
388
|
+
<Pencil className="size-3.5" />
|
|
389
|
+
</Button>
|
|
390
|
+
)}
|
|
391
|
+
</div>
|
|
311
392
|
)}
|
|
312
|
-
{(l.sku || l.unit) && (
|
|
313
|
-
<p className="mt-0.5 text-xs text-muted-foreground">{[l.sku, !show('unit') ? l.unit : ''].filter(Boolean).join(' · ')}</p>
|
|
393
|
+
{(l.sku || unitText(l.unit)) && (
|
|
394
|
+
<p className="mt-0.5 text-xs text-muted-foreground">{[l.sku, !show('unit') ? unitText(l.unit) : ''].filter(Boolean).join(' · ')}</p>
|
|
395
|
+
)}
|
|
396
|
+
{l.catalog_pending && !locked && (
|
|
397
|
+
<p className="mt-0.5 text-xs text-muted-foreground" data-slot="catalog-pending" role="status">
|
|
398
|
+
{l.catalog_pending.unit_price != null
|
|
399
|
+
? t('lineItems.catalogPriceChanged', {
|
|
400
|
+
defaultValue: 'Precio de catálogo cambió a {{price}}',
|
|
401
|
+
price: fmt.money(l.catalog_pending.unit_price),
|
|
402
|
+
})
|
|
403
|
+
: t('lineItems.catalogChanged', { defaultValue: 'El producto cambió en el catálogo' })}
|
|
404
|
+
{' — '}
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
className="font-medium text-foreground underline-offset-2 hover:underline focus-visible:underline focus-visible:outline-none"
|
|
408
|
+
onClick={() => emit(latest.current.map((x) => (x.key === l.key ? applyCatalogPending(x) : x)))}
|
|
409
|
+
>
|
|
410
|
+
{t('lineItems.catalogApply', { defaultValue: 'aplicar' })}
|
|
411
|
+
</button>
|
|
412
|
+
</p>
|
|
314
413
|
)}
|
|
315
414
|
{cell(i, 'description') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'description')}</p>}
|
|
316
415
|
</td>
|
|
@@ -474,10 +573,12 @@ export function DocumentLinesGrid({
|
|
|
474
573
|
search={search}
|
|
475
574
|
warehouseId={warehouseId}
|
|
476
575
|
currency={currency}
|
|
477
|
-
onPick={pickProduct}
|
|
576
|
+
onPick={(p, v) => pickProduct(p, v)}
|
|
478
577
|
onFree={addFreeFromDraft}
|
|
479
578
|
onAddBlank={() => emit([...value, blankLine()])}
|
|
480
|
-
|
|
579
|
+
onCreate={createProduct ? (q) => void createProduct(q) : undefined}
|
|
580
|
+
entityLabel={entity}
|
|
581
|
+
span={colCount - 2}
|
|
481
582
|
/>
|
|
482
583
|
)}
|
|
483
584
|
</tbody>
|
|
@@ -542,6 +643,15 @@ export function DocumentLinesGrid({
|
|
|
542
643
|
)
|
|
543
644
|
}
|
|
544
645
|
|
|
646
|
+
/** Capturar a mano un campo con aviso de catálogo responde el aviso: deja de ofrecer «aplicar» para ese campo. */
|
|
647
|
+
function withoutAnsweredPending(line: LineItem, p: Partial<LineItem>): LineItem {
|
|
648
|
+
if (!line.catalog_pending) return line
|
|
649
|
+
const pending = { ...line.catalog_pending }
|
|
650
|
+
for (const k of Object.keys(p)) delete (pending as Record<string, unknown>)[k]
|
|
651
|
+
const { catalog_pending: _drop, ...rest } = line
|
|
652
|
+
return Object.keys(pending).length > 0 ? { ...rest, catalog_pending: pending } : rest
|
|
653
|
+
}
|
|
654
|
+
|
|
545
655
|
/** Mismo componente. El nombre anterior sigue exportado. */
|
|
546
656
|
export const LineItemsEditor = DocumentLinesGrid
|
|
547
657
|
|
|
@@ -561,9 +671,14 @@ function DraftRow({
|
|
|
561
671
|
onPick,
|
|
562
672
|
onFree,
|
|
563
673
|
onAddBlank,
|
|
564
|
-
|
|
674
|
+
onCreate,
|
|
675
|
+
entityLabel,
|
|
676
|
+
span,
|
|
565
677
|
}: {
|
|
566
|
-
|
|
678
|
+
/** Columnas que ocupa el buscador (todas menos «Importe» y acciones). */
|
|
679
|
+
span: number
|
|
680
|
+
onCreate?: (query: string) => void
|
|
681
|
+
entityLabel?: string
|
|
567
682
|
search: NonNullable<DocumentLinesGridProps['search']>
|
|
568
683
|
warehouseId?: string
|
|
569
684
|
currency?: string
|
|
@@ -575,7 +690,10 @@ function DraftRow({
|
|
|
575
690
|
const [text, setText] = useState('')
|
|
576
691
|
return (
|
|
577
692
|
<tr className="border-t border-dashed" data-slot="line-draft-row">
|
|
578
|
-
|
|
693
|
+
{/* El renglón vacío no tiene cantidad ni precio todavía: el buscador
|
|
694
|
+
usa ese ancho (antes medía solo la columna «Descripción» y el
|
|
695
|
+
texto de ayuda se cortaba). */}
|
|
696
|
+
<td className={CELL} colSpan={span}>
|
|
579
697
|
<LineProductCell
|
|
580
698
|
search={search}
|
|
581
699
|
text={text}
|
|
@@ -595,15 +713,21 @@ function DraftRow({
|
|
|
595
713
|
warehouseId={warehouseId}
|
|
596
714
|
currency={currency}
|
|
597
715
|
ariaLabel={t('lineItems.searchProduct', { defaultValue: 'Buscar producto' })}
|
|
598
|
-
placeholder={t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
|
|
599
716
|
className="border-dashed bg-transparent shadow-none"
|
|
600
717
|
dataCell="search"
|
|
718
|
+
onCreate={
|
|
719
|
+
onCreate
|
|
720
|
+
? (q) => {
|
|
721
|
+
setText('')
|
|
722
|
+
onCreate(q)
|
|
723
|
+
}
|
|
724
|
+
: undefined
|
|
725
|
+
}
|
|
726
|
+
entityLabel={entityLabel}
|
|
601
727
|
/>
|
|
602
728
|
</td>
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
<td key={i} aria-hidden />
|
|
606
|
-
))}
|
|
729
|
+
<td aria-hidden />
|
|
730
|
+
<td aria-hidden />
|
|
607
731
|
</tr>
|
|
608
732
|
)
|
|
609
733
|
}
|
|
@@ -625,6 +749,10 @@ function TotalRow({ span, label, value, slot, strong }: { span: number; label: s
|
|
|
625
749
|
const DELETE =
|
|
626
750
|
'size-8 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 max-sm:opacity-100'
|
|
627
751
|
|
|
752
|
+
/** Lápiz «Editar producto» dentro de la celda: al pasar o enfocar la fila; siempre con teclado y en móvil. */
|
|
753
|
+
const EDIT =
|
|
754
|
+
'absolute right-0.5 top-1/2 size-7 -translate-y-1/2 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 max-sm:opacity-100'
|
|
755
|
+
|
|
628
756
|
/** Celda densa: misma altura en todos los renglones. */
|
|
629
757
|
const CELL = 'px-2 py-1.5 align-top'
|
|
630
758
|
/** Inputs de celda: un poco más bajos que los del formulario (densidad de editor). */
|