@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
|
@@ -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). */
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// guardado de renglones fallaba (PIT-018) por mandar al API cadenas vacías y
|
|
4
4
|
// claves de UI; `serializeLineItems` es la única puerta de salida hacia el backend.
|
|
5
5
|
import { roundMoney, toAmount } from './format'
|
|
6
|
+
import type { ProductVariant } from './product-search'
|
|
6
7
|
|
|
7
8
|
export type LineItemKind = 'item' | 'section' | 'note'
|
|
8
9
|
|
|
@@ -50,6 +51,36 @@ export interface LineItem {
|
|
|
50
51
|
* del host) — el SDK no conoce sus claves.
|
|
51
52
|
*/
|
|
52
53
|
extensions?: Record<string, string>
|
|
54
|
+
/**
|
|
55
|
+
* Valores que el catálogo escribió en el renglón la última vez que se
|
|
56
|
+
* aplicó el producto (elegirlo, crearlo o editarlo desde la celda). Un campo
|
|
57
|
+
* cuyo valor actual difiere de su foto es un override del usuario («dirty»)
|
|
58
|
+
* y una edición del producto no lo pisa. No se serializa.
|
|
59
|
+
*/
|
|
60
|
+
catalog?: LineCatalogSnapshot
|
|
61
|
+
/**
|
|
62
|
+
* Valores nuevos del catálogo que NO se aplicaron porque el usuario ya
|
|
63
|
+
* había cambiado ese campo a mano: la celda ofrece «aplicar». No se serializa.
|
|
64
|
+
*/
|
|
65
|
+
catalog_pending?: Partial<Pick<LineItem, CatalogLineField>>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Campos del renglón que salen del producto y el usuario puede sobrescribir. */
|
|
69
|
+
export type CatalogLineField = 'description' | 'sku' | 'unit_price' | 'tax_rate' | 'unit'
|
|
70
|
+
|
|
71
|
+
export const CATALOG_LINE_FIELDS: readonly CatalogLineField[] = ['description', 'sku', 'unit_price', 'tax_rate', 'unit']
|
|
72
|
+
|
|
73
|
+
/** Foto de lo que el catálogo escribió en el renglón (por campo) y de qué producto/variante. */
|
|
74
|
+
export interface LineCatalogSnapshot {
|
|
75
|
+
description?: string
|
|
76
|
+
sku?: string
|
|
77
|
+
unit_price?: number
|
|
78
|
+
tax_rate?: number
|
|
79
|
+
unit?: string
|
|
80
|
+
/** Id del producto del catálogo (el registro que se edita), aunque el renglón lleve una variante. */
|
|
81
|
+
product_ref?: string
|
|
82
|
+
/** Variante elegida: al refrescar se vuelve a aplicar la misma. */
|
|
83
|
+
variant?: ProductVariant
|
|
53
84
|
}
|
|
54
85
|
|
|
55
86
|
/** Qué hacer si `quantity > available`. */
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
// Teclado: ↑/↓ recorren, Enter elige (el grid pasa el foco a «Cant.»), Esc
|
|
9
9
|
// cierra, Tab cierra y avanza normal. El texto escrito sin elegir queda como
|
|
10
10
|
// descripción libre (el renglón libre sigue funcionando igual).
|
|
11
|
+
//
|
|
12
|
+
// Con `onCreate`, la celda ofrece crear el producto como las apps top: «+» unido
|
|
13
|
+
// al buscador y «Crear producto «texto»» al pie de la lista, prellenado con lo
|
|
14
|
+
// buscado. El grid llena el renglón con el producto nuevo al guardarlo.
|
|
11
15
|
import { forwardRef, useState, type KeyboardEvent } from 'react'
|
|
12
16
|
import { useTranslation } from 'react-i18next'
|
|
13
17
|
import { RecordPicker } from '../record-picker'
|
|
@@ -47,10 +51,16 @@ export interface LineProductCellProps {
|
|
|
47
51
|
className?: string
|
|
48
52
|
/** Atributos de datos para que el grid ubique la celda (foco por teclado). */
|
|
49
53
|
dataCell?: string
|
|
54
|
+
/** Alta del producto desde la celda, prellenada con lo buscado. */
|
|
55
|
+
onCreate?: (query: string) => void
|
|
56
|
+
/** Nombre del modelo para «Crear …» (default «producto»). */
|
|
57
|
+
entityLabel?: string
|
|
58
|
+
/** Detalle de qué se puede buscar: tooltip del buscador y estado sin coincidencias. */
|
|
59
|
+
hint?: string
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps>(function LineProductCell(
|
|
53
|
-
{ search, text, onTextChange, onPick, onKeyDownClosed, warehouseId, currency, placeholder, ariaLabel, disabled, invalid, className, dataCell },
|
|
63
|
+
{ search, text, onTextChange, onPick, onKeyDownClosed, warehouseId, currency, placeholder, ariaLabel, disabled, invalid, className, dataCell, onCreate, entityLabel, hint },
|
|
54
64
|
ref,
|
|
55
65
|
) {
|
|
56
66
|
const { t } = useTranslation()
|
|
@@ -58,6 +68,8 @@ export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps
|
|
|
58
68
|
// capturado no despliega «sin resultados» encima de la tabla.
|
|
59
69
|
const [typing, setTyping] = useState(false)
|
|
60
70
|
const { hits, loading, minChars } = useProductSearch(typing ? text : '', search, typing)
|
|
71
|
+
const entity = entityLabel ?? t('lineItems.productEntity', { defaultValue: 'producto' })
|
|
72
|
+
const searchHint = hint ?? t('lineItems.searchHint', { defaultValue: 'Busca por nombre, SKU o código de barras' })
|
|
61
73
|
|
|
62
74
|
return (
|
|
63
75
|
<RecordPicker<ProductHit>
|
|
@@ -66,7 +78,8 @@ export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps
|
|
|
66
78
|
freeText
|
|
67
79
|
anchorSlot="line-product-cell"
|
|
68
80
|
contentSlot="line-product-results"
|
|
69
|
-
minListWidth="
|
|
81
|
+
minListWidth="26.25rem"
|
|
82
|
+
maxListWidth="35rem"
|
|
70
83
|
items={hits}
|
|
71
84
|
loading={loading}
|
|
72
85
|
minChars={minChars}
|
|
@@ -85,10 +98,24 @@ export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps
|
|
|
85
98
|
disabled={disabled}
|
|
86
99
|
invalid={invalid}
|
|
87
100
|
triggerClassName={className}
|
|
88
|
-
triggerProps={{ 'data-cell': dataCell }}
|
|
89
|
-
placeholder={placeholder ?? t('lineItems.
|
|
101
|
+
triggerProps={{ 'data-cell': dataCell, title: searchHint }}
|
|
102
|
+
placeholder={placeholder ?? t('lineItems.searchPlaceholderShort', { defaultValue: 'Buscar producto…' })}
|
|
90
103
|
loadingText={t('common.searching', { defaultValue: 'Buscando…' })}
|
|
91
|
-
emptyText={
|
|
104
|
+
emptyText={
|
|
105
|
+
<span className="block space-y-1">
|
|
106
|
+
<span className="block">{t('lineItems.noProduct', { defaultValue: 'Sin coincidencias. Se usará como descripción libre.' })}</span>
|
|
107
|
+
<span className="block text-xs">{searchHint}</span>
|
|
108
|
+
</span>
|
|
109
|
+
}
|
|
110
|
+
onCreate={onCreate}
|
|
111
|
+
entityLabel={entity}
|
|
112
|
+
createLabel={t('lineItems.createProduct', { defaultValue: 'Crear {{entity}}', entity })}
|
|
113
|
+
createFooterLabel={(q) => (
|
|
114
|
+
<>
|
|
115
|
+
{t('lineItems.createProduct', { defaultValue: 'Crear {{entity}}', entity })}
|
|
116
|
+
{q ? <span className="text-muted-foreground"> «{q}»</span> : null}
|
|
117
|
+
</>
|
|
118
|
+
)}
|
|
92
119
|
/>
|
|
93
120
|
)
|
|
94
121
|
})
|
|
@@ -108,7 +108,7 @@ export function ProductHitRow({ hit, warehouseId, currency, compact }: { hit: Pr
|
|
|
108
108
|
return (
|
|
109
109
|
<span className="flex w-full min-w-0 items-center justify-between gap-3">
|
|
110
110
|
<span className="min-w-0">
|
|
111
|
-
<span className="
|
|
111
|
+
<span className="line-clamp-2 break-words font-medium" title={productHitLabel(hit)}>{productHitLabel(hit)}</span>
|
|
112
112
|
{meta && <span className="block truncate text-xs text-muted-foreground">{meta}</span>}
|
|
113
113
|
</span>
|
|
114
114
|
<span className="flex shrink-0 items-center gap-2">
|
|
@@ -1334,7 +1334,7 @@ function FieldRow({ field, record, value, mode, onChange, error, locked }: Field
|
|
|
1334
1334
|
|
|
1335
1335
|
return (
|
|
1336
1336
|
<div className="flex flex-col gap-1.5" data-aby-field={field.key}>
|
|
1337
|
-
<Label className="text-
|
|
1337
|
+
<Label className="text-sm font-medium text-foreground/80">
|
|
1338
1338
|
{localizeFieldLabel(field.label, t)}
|
|
1339
1339
|
{field.required && mode !== 'view' && !isEditReadonly && (
|
|
1340
1340
|
<span className="text-destructive ml-0.5">*</span>
|
package/src/field-grid.tsx
CHANGED
|
@@ -42,15 +42,16 @@ export function FieldCell({
|
|
|
42
42
|
export function FieldLabel({
|
|
43
43
|
htmlFor,
|
|
44
44
|
required,
|
|
45
|
-
tone = '
|
|
45
|
+
tone = 'sentence',
|
|
46
46
|
children,
|
|
47
47
|
}: {
|
|
48
48
|
htmlFor?: string
|
|
49
49
|
required?: boolean
|
|
50
50
|
/**
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
53
|
-
* uppercase labels
|
|
51
|
+
* `sentence` (default): sentence-case label — every form of the runtime
|
|
52
|
+
* (CRUD modal, action modal, DocumentEditor) reads the same and a column of
|
|
53
|
+
* uppercase labels no longer shouts. `caps`: small uppercase eyebrow, for
|
|
54
|
+
* callers that still want it.
|
|
54
55
|
*/
|
|
55
56
|
tone?: 'caps' | 'sentence'
|
|
56
57
|
children: ReactNode
|
package/src/record-picker.tsx
CHANGED
|
@@ -268,6 +268,8 @@ export interface RecordPickerProps<T> {
|
|
|
268
268
|
inputRef?: Ref<HTMLInputElement>
|
|
269
269
|
/** Floor for the list width (CSS length). Default 14rem. */
|
|
270
270
|
minListWidth?: string
|
|
271
|
+
/** Ceiling for the list width (CSS length), e.g. under a very wide trigger. Default: none. */
|
|
272
|
+
maxListWidth?: string
|
|
271
273
|
/** Rendered after the joined group (e.g. a scan button). */
|
|
272
274
|
after?: ReactNode
|
|
273
275
|
/** Rendered under the control (badges, hints). */
|
|
@@ -351,6 +353,7 @@ export function RecordPicker<T>(props: RecordPickerProps<T>) {
|
|
|
351
353
|
triggerProps,
|
|
352
354
|
inputRef,
|
|
353
355
|
minListWidth = '14rem',
|
|
356
|
+
maxListWidth,
|
|
354
357
|
after,
|
|
355
358
|
below,
|
|
356
359
|
beforeList,
|
|
@@ -555,7 +558,15 @@ export function RecordPicker<T>(props: RecordPickerProps<T>) {
|
|
|
555
558
|
)
|
|
556
559
|
|
|
557
560
|
const list = (
|
|
558
|
-
<div
|
|
561
|
+
<div
|
|
562
|
+
className="overflow-y-auto overflow-x-hidden overscroll-contain"
|
|
563
|
+
data-slot="record-picker-list"
|
|
564
|
+
// Never taller than the room Radix measured on the side it opened
|
|
565
|
+
// (flipped above the field near the bottom of a modal it used to
|
|
566
|
+
// spill past the top of the viewport): 18rem at most, minus the
|
|
567
|
+
// search box in button mode; the list scrolls inside.
|
|
568
|
+
style={{ maxHeight: `min(18rem, calc(var(--radix-popover-content-available-height, 18rem) - ${isInput ? '2px' : '2.75rem'}))` }}
|
|
569
|
+
>
|
|
559
570
|
{beforeList}
|
|
560
571
|
{status}
|
|
561
572
|
<ul
|
|
@@ -596,7 +607,7 @@ export function RecordPicker<T>(props: RecordPickerProps<T>) {
|
|
|
596
607
|
) : null}
|
|
597
608
|
{renderLead?.(item, 'option')}
|
|
598
609
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
599
|
-
<span className="
|
|
610
|
+
<span className="line-clamp-2 break-words">{getLabel(item)}</span>
|
|
600
611
|
{desc != null && desc !== '' ? (
|
|
601
612
|
<span className="truncate text-xs text-muted-foreground">{desc}</span>
|
|
602
613
|
) : null}
|
|
@@ -636,7 +647,9 @@ export function RecordPicker<T>(props: RecordPickerProps<T>) {
|
|
|
636
647
|
)
|
|
637
648
|
|
|
638
649
|
const contentStyle = {
|
|
639
|
-
width:
|
|
650
|
+
width: maxListWidth
|
|
651
|
+
? `min(max(var(--radix-popover-trigger-width), ${minListWidth}), ${maxListWidth})`
|
|
652
|
+
: `max(var(--radix-popover-trigger-width), ${minListWidth})`,
|
|
640
653
|
maxWidth: 'calc(100vw - 1rem)',
|
|
641
654
|
zIndex: RECORD_PICKER_Z_INDEX,
|
|
642
655
|
}
|