@asteby/metacore-runtime-react 46.2.3 → 46.3.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/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +16 -4
- package/dist/business/document-lines.d.ts +30 -0
- package/dist/business/document-lines.d.ts.map +1 -0
- package/dist/business/document-lines.js +65 -0
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-items-editor.d.ts +28 -4
- package/dist/business/line-items-editor.d.ts.map +1 -1
- package/dist/business/line-items-editor.js +139 -14
- package/dist/business/line-items.d.ts +24 -2
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +50 -5
- package/dist/business/product-search.d.ts +4 -0
- package/dist/business/product-search.d.ts.map +1 -1
- package/dist/business/product-search.js +5 -1
- package/dist/dynamic-form-schema.d.ts +9 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +61 -0
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +18 -8
- package/dist/dynamic-select-field.d.ts +3 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +2 -1
- package/dist/use-options-resolver.d.ts +5 -5
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +11 -0
- package/package.json +1 -1
- package/src/__tests__/document-lines-grid.test.tsx +79 -0
- package/src/__tests__/line-item-formulas.test.ts +18 -1
- package/src/__tests__/use-options-resolver.test.ts +5 -0
- package/src/business/business.test.ts +43 -0
- package/src/business/catalog.ts +16 -4
- package/src/business/document-lines.ts +91 -0
- package/src/business/index.ts +1 -0
- package/src/business/line-items-editor.tsx +365 -46
- package/src/business/line-items.ts +65 -7
- package/src/business/product-search.ts +9 -1
- package/src/dynamic-form-schema.ts +60 -0
- package/src/dynamic-line-items.tsx +23 -3
- package/src/dynamic-select-field.tsx +4 -0
- package/src/use-options-resolver.ts +15 -0
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
//
|
|
1
|
+
// DocumentLinesGrid — editor único de renglones (UX-2).
|
|
2
|
+
// `LineItemsEditor` es el mismo componente: las props nuevas son opcionales.
|
|
2
3
|
// Controlado: `value`/`onChange` con `LineItem[]`. Guardar = `serializeLineItems`.
|
|
3
|
-
import { useMemo } from 'react'
|
|
4
|
+
import { useMemo, useState } from 'react'
|
|
4
5
|
import { useTranslation } from 'react-i18next'
|
|
5
|
-
import { Plus,
|
|
6
|
+
import { Heading, Minus, Plus, Search, StickyNote, Trash2 } from 'lucide-react'
|
|
6
7
|
import { Button, Input } from '@asteby/metacore-ui'
|
|
7
8
|
import { useCan } from '../permissions-context'
|
|
8
9
|
import { useFormatter } from './format'
|
|
10
|
+
import {
|
|
11
|
+
addProductLine,
|
|
12
|
+
lineGridKeyCommand,
|
|
13
|
+
type DocumentLinesMode,
|
|
14
|
+
type PriceSource,
|
|
15
|
+
} from './document-lines'
|
|
9
16
|
import {
|
|
10
17
|
computeLine,
|
|
11
18
|
computeTotals,
|
|
@@ -15,10 +22,17 @@ import {
|
|
|
15
22
|
type LineItemsPolicy,
|
|
16
23
|
type LineItemsValidation,
|
|
17
24
|
} from './line-items'
|
|
25
|
+
import { useAsyncSearch } from './use-async-search'
|
|
26
|
+
import {
|
|
27
|
+
parseProductQuery,
|
|
28
|
+
type ProductQuery,
|
|
29
|
+
type ProductResult,
|
|
30
|
+
type ProductVariant,
|
|
31
|
+
} from './product-search'
|
|
18
32
|
|
|
19
|
-
export type LineItemsColumn = 'discount' | 'tax' | 'technician' | 'lot' | 'dot'
|
|
33
|
+
export type LineItemsColumn = 'discount' | 'tax' | 'technician' | 'lot' | 'dot' | 'unit'
|
|
20
34
|
|
|
21
|
-
export interface
|
|
35
|
+
export interface DocumentLinesGridProps {
|
|
22
36
|
value: LineItem[]
|
|
23
37
|
/** Se emite con cada cambio; las líneas siempre traen `key`. */
|
|
24
38
|
onChange: (lines: LineItem[]) => void
|
|
@@ -29,8 +43,24 @@ export interface LineItemsEditorProps {
|
|
|
29
43
|
serverErrors?: Record<string, string>
|
|
30
44
|
/** Se emite tras cada cambio con el resultado de validar (para bloquear «Guardar»). */
|
|
31
45
|
onValidate?: (v: LineItemsValidation) => void
|
|
32
|
-
/** Slot: abre el ProductPicker
|
|
46
|
+
/** Slot legado: abre el ProductPicker. Con `search`, el buscador va en la fila. */
|
|
33
47
|
onRequestProduct?: () => void
|
|
48
|
+
/**
|
|
49
|
+
* Búsqueda en la fila (mismo contrato que ProductPicker). Al elegir, el renglón
|
|
50
|
+
* se llena aquí; un segundo clic del mismo producto suma cantidad.
|
|
51
|
+
*/
|
|
52
|
+
search?: (query: Exclude<ProductQuery, { kind: 'empty' }>, signal: AbortSignal) => Promise<ProductResult[]>
|
|
53
|
+
/** `sale` usa el precio de lista; `cost` el costo de catálogo (compras). Default `sale`. */
|
|
54
|
+
priceSource?: PriceSource
|
|
55
|
+
/** `from_source` oculta alta/borrado y permite cantidad 0 hasta `max_quantity`. */
|
|
56
|
+
mode?: DocumentLinesMode
|
|
57
|
+
/** Cómo se captura el descuento. Default `percent` (el comportamiento anterior). */
|
|
58
|
+
discountMode?: 'percent' | 'amount' | 'both'
|
|
59
|
+
/** − / cantidad / +. Default: activo cuando hay `search`. */
|
|
60
|
+
quantityStepper?: boolean
|
|
61
|
+
/** Un segundo clic incrementa la cantidad del mismo producto. Default true. */
|
|
62
|
+
mergeSameProduct?: boolean
|
|
63
|
+
warehouseId?: string
|
|
34
64
|
/** Permite secciones y notas (Odoo-style). Default true. */
|
|
35
65
|
allowSections?: boolean
|
|
36
66
|
readOnly?: boolean
|
|
@@ -40,9 +70,12 @@ export interface LineItemsEditorProps {
|
|
|
40
70
|
currency?: string
|
|
41
71
|
}
|
|
42
72
|
|
|
73
|
+
/** @deprecated Usa `DocumentLinesGridProps`. Es el mismo contrato. */
|
|
74
|
+
export type LineItemsEditorProps = DocumentLinesGridProps
|
|
75
|
+
|
|
43
76
|
const num = (s: string): number => (s.trim() === '' ? 0 : Number(s.replace(',', '.')) || 0)
|
|
44
77
|
|
|
45
|
-
export function
|
|
78
|
+
export function DocumentLinesGrid({
|
|
46
79
|
value,
|
|
47
80
|
onChange,
|
|
48
81
|
columns = ['discount'],
|
|
@@ -50,46 +83,89 @@ export function LineItemsEditor({
|
|
|
50
83
|
serverErrors,
|
|
51
84
|
onValidate,
|
|
52
85
|
onRequestProduct,
|
|
86
|
+
search,
|
|
87
|
+
priceSource = 'sale',
|
|
88
|
+
mode = 'free',
|
|
89
|
+
discountMode = 'percent',
|
|
90
|
+
quantityStepper,
|
|
91
|
+
mergeSameProduct = true,
|
|
92
|
+
warehouseId,
|
|
53
93
|
allowSections = true,
|
|
54
94
|
readOnly = false,
|
|
55
95
|
editPermission,
|
|
56
96
|
currency,
|
|
57
|
-
}:
|
|
97
|
+
}: DocumentLinesGridProps) {
|
|
58
98
|
const { t } = useTranslation()
|
|
59
99
|
const can = useCan()
|
|
60
100
|
const fmt = useFormatter({ currency })
|
|
61
101
|
const locked = readOnly || (editPermission ? !can(editPermission) : false)
|
|
62
|
-
const
|
|
63
|
-
const
|
|
102
|
+
const fromSource = mode === 'from_source'
|
|
103
|
+
const effectivePolicy = useMemo<LineItemsPolicy>(
|
|
104
|
+
() => (fromSource ? { ...policy, allowZeroQuantity: policy?.allowZeroQuantity ?? true } : (policy ?? {})),
|
|
105
|
+
[policy, fromSource],
|
|
106
|
+
)
|
|
107
|
+
const view = useMemo(
|
|
108
|
+
() =>
|
|
109
|
+
discountMode === 'amount'
|
|
110
|
+
? value.map((l) => (l.kind === 'item' ? { ...l, discount_kind: 'amount' as const } : l))
|
|
111
|
+
: value,
|
|
112
|
+
[value, discountMode],
|
|
113
|
+
)
|
|
114
|
+
const validation = useMemo(() => validateLineItems(view, effectivePolicy), [view, effectivePolicy])
|
|
115
|
+
const totals = useMemo(() => computeTotals(view), [view])
|
|
64
116
|
const errors = { ...validation.errors, ...serverErrors }
|
|
65
117
|
const show = (c: LineItemsColumn) => columns.includes(c)
|
|
118
|
+
const stepper = quantityStepper ?? !!search
|
|
119
|
+
const canAdd = !locked && !fromSource
|
|
120
|
+
const [undo, setUndo] = useState<LineItem[] | null>(null)
|
|
66
121
|
|
|
67
122
|
const emit = (next: LineItem[]) => {
|
|
68
123
|
onChange(next)
|
|
69
|
-
|
|
124
|
+
const priced = discountMode === 'amount' ? next.map((l) => (l.kind === 'item' ? { ...l, discount_kind: 'amount' as const } : l)) : next
|
|
125
|
+
onValidate?.(validateLineItems(priced, effectivePolicy))
|
|
70
126
|
}
|
|
71
|
-
const patch = (idx: number, p: Partial<LineItem>) =>
|
|
127
|
+
const patch = (idx: number, p: Partial<LineItem>) => {
|
|
128
|
+
if (discountMode === 'amount') p = { ...p, discount_kind: 'amount' }
|
|
72
129
|
emit(value.map((l, i) => (i === idx ? { ...l, ...p } : l)))
|
|
73
|
-
|
|
130
|
+
}
|
|
131
|
+
const remove = (idx: number) => {
|
|
132
|
+
setUndo(value)
|
|
133
|
+
emit(value.filter((_, i) => i !== idx))
|
|
134
|
+
}
|
|
74
135
|
|
|
75
136
|
const cell = (i: number, field: keyof LineItem) => errors[`${i}.${field}`]
|
|
76
137
|
const warn = (i: number, field: string) => validation.warnings[`${i}.${field}`]
|
|
77
138
|
|
|
139
|
+
const pickProduct = (product: ProductResult, variant?: ProductVariant) => {
|
|
140
|
+
emit(addProductLine(value, product, variant, { priceSource, warehouseId, mergeSameProduct }))
|
|
141
|
+
}
|
|
142
|
+
|
|
78
143
|
return (
|
|
79
|
-
<div data-slot="line-items-editor" className="space-y-2">
|
|
144
|
+
<div data-slot="line-items-editor" data-component="DocumentLinesGrid" data-mode={mode} className="space-y-2">
|
|
80
145
|
<div className="overflow-x-auto rounded-md border">
|
|
81
146
|
<table className="w-full text-sm">
|
|
82
147
|
<thead className="bg-muted/50 text-left text-xs text-muted-foreground">
|
|
83
148
|
<tr>
|
|
84
149
|
<th className="px-2 py-2">{t('lineItems.description', { defaultValue: 'Descripción' })}</th>
|
|
85
|
-
<th className="w-
|
|
86
|
-
<th className="w-
|
|
87
|
-
|
|
150
|
+
{show('unit') && <th className="w-20 px-2 py-2">{t('lineItems.unit', { defaultValue: 'Unidad' })}</th>}
|
|
151
|
+
<th className="w-36 px-2 py-2 text-right">{t('lineItems.quantity', { defaultValue: 'Cant.' })}</th>
|
|
152
|
+
<th className="w-32 px-2 py-2 text-right">
|
|
153
|
+
{priceSource === 'cost'
|
|
154
|
+
? t('lineItems.unitCost', { defaultValue: 'Costo unitario' })
|
|
155
|
+
: t('lineItems.unitPrice', { defaultValue: 'Precio' })}
|
|
156
|
+
</th>
|
|
157
|
+
{show('discount') && (
|
|
158
|
+
<th className="w-28 px-2 py-2 text-right">
|
|
159
|
+
{discountMode === 'amount'
|
|
160
|
+
? t('lineItems.discountAmount', { defaultValue: 'Desc.' })
|
|
161
|
+
: t('lineItems.discount', { defaultValue: 'Desc. %' })}
|
|
162
|
+
</th>
|
|
163
|
+
)}
|
|
88
164
|
{show('tax') && <th className="w-20 px-2 py-2 text-right">{t('lineItems.tax', { defaultValue: 'IVA %' })}</th>}
|
|
89
165
|
{show('technician') && <th className="w-32 px-2 py-2">{t('lineItems.technician', { defaultValue: 'Técnico' })}</th>}
|
|
90
166
|
{show('lot') && <th className="w-28 px-2 py-2">{t('lineItems.lot', { defaultValue: 'Lote' })}</th>}
|
|
91
167
|
{show('dot') && <th className="w-24 px-2 py-2">DOT</th>}
|
|
92
|
-
<th className="w-32 px-2 py-2 text-right">{t('lineItems.
|
|
168
|
+
<th className="w-32 px-2 py-2 text-right">{t('lineItems.amount', { defaultValue: 'Importe' })}</th>
|
|
93
169
|
<th className="w-10" />
|
|
94
170
|
</tr>
|
|
95
171
|
</thead>
|
|
@@ -107,8 +183,15 @@ export function LineItemsEditor({
|
|
|
107
183
|
placeholder={l.kind === 'section' ? t('lineItems.sectionPlaceholder', { defaultValue: 'Sección' }) : t('lineItems.notePlaceholder', { defaultValue: 'Nota' })}
|
|
108
184
|
className={l.kind === 'section' ? 'font-semibold' : 'italic'}
|
|
109
185
|
onChange={(e) => patch(i, { description: e.target.value })}
|
|
186
|
+
onKeyDown={(e) => {
|
|
187
|
+
const cmd = lineGridKeyCommand(e.key, { cellEmpty: !l.description.trim(), mode })
|
|
188
|
+
if (cmd === 'delete') {
|
|
189
|
+
e.preventDefault()
|
|
190
|
+
remove(i)
|
|
191
|
+
}
|
|
192
|
+
}}
|
|
110
193
|
/>
|
|
111
|
-
{
|
|
194
|
+
{canAdd && (
|
|
112
195
|
<Button type="button" size="icon" variant="ghost" onClick={() => remove(i)} aria-label={t('common.delete', { defaultValue: 'Eliminar' })}>
|
|
113
196
|
<Trash2 className="size-4" />
|
|
114
197
|
</Button>
|
|
@@ -119,28 +202,97 @@ export function LineItemsEditor({
|
|
|
119
202
|
</tr>
|
|
120
203
|
)
|
|
121
204
|
}
|
|
122
|
-
const
|
|
205
|
+
const shown = view[i] ?? l
|
|
206
|
+
const a = computeLine(shown)
|
|
207
|
+
const kind = shown.discount_kind ?? 'percent'
|
|
208
|
+
const qtyMax = l.max_quantity
|
|
123
209
|
return (
|
|
124
|
-
<tr key={l.key} className="border-t align-top">
|
|
210
|
+
<tr key={l.key} className="border-t align-top" data-line-key={l.key}>
|
|
125
211
|
<td className="px-2 py-1.5">
|
|
126
212
|
<Input
|
|
127
213
|
value={l.description}
|
|
128
214
|
disabled={locked}
|
|
215
|
+
aria-label={t('lineItems.description', { defaultValue: 'Descripción' })}
|
|
129
216
|
aria-invalid={!!cell(i, 'description')}
|
|
130
217
|
onChange={(e) => patch(i, { description: e.target.value })}
|
|
218
|
+
onKeyDown={(e) => {
|
|
219
|
+
const cmd = lineGridKeyCommand(e.key, {
|
|
220
|
+
cellEmpty: !l.description.trim() && !l.product_id,
|
|
221
|
+
mode,
|
|
222
|
+
})
|
|
223
|
+
if (cmd === 'delete') {
|
|
224
|
+
e.preventDefault()
|
|
225
|
+
remove(i)
|
|
226
|
+
} else if (cmd === 'add') {
|
|
227
|
+
e.preventDefault()
|
|
228
|
+
emit([...value, makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {})])
|
|
229
|
+
}
|
|
230
|
+
}}
|
|
131
231
|
/>
|
|
132
|
-
{l.sku
|
|
232
|
+
{(l.sku || l.unit) && (
|
|
233
|
+
<p className="mt-0.5 text-xs text-muted-foreground">{[l.sku, !show('unit') ? l.unit : ''].filter(Boolean).join(' · ')}</p>
|
|
234
|
+
)}
|
|
133
235
|
{cell(i, 'description') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'description')}</p>}
|
|
134
236
|
</td>
|
|
237
|
+
{show('unit') && (
|
|
238
|
+
<td className="px-2 py-1.5">
|
|
239
|
+
<Input value={l.unit ?? ''} disabled={locked} onChange={(e) => patch(i, { unit: e.target.value })} />
|
|
240
|
+
</td>
|
|
241
|
+
)}
|
|
135
242
|
<td className="px-2 py-1.5">
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
243
|
+
{stepper ? (
|
|
244
|
+
<div className="flex items-center justify-end gap-1">
|
|
245
|
+
<Button
|
|
246
|
+
type="button"
|
|
247
|
+
size="icon"
|
|
248
|
+
variant="outline"
|
|
249
|
+
className="size-7"
|
|
250
|
+
disabled={locked || toNum(l.quantity) <= (fromSource ? 0 : 1)}
|
|
251
|
+
aria-label={t('lineItems.decreaseQty', { defaultValue: 'Disminuir cantidad' })}
|
|
252
|
+
onClick={() => patch(i, { quantity: Math.max(fromSource ? 0 : 1, toNum(l.quantity) - 1) })}
|
|
253
|
+
>
|
|
254
|
+
<Minus className="size-3" />
|
|
255
|
+
</Button>
|
|
256
|
+
<Input
|
|
257
|
+
inputMode="decimal"
|
|
258
|
+
className="h-8 w-14 text-right"
|
|
259
|
+
value={String(l.quantity)}
|
|
260
|
+
disabled={locked}
|
|
261
|
+
aria-label={t('lineItems.quantity', { defaultValue: 'Cant.' })}
|
|
262
|
+
aria-invalid={!!cell(i, 'quantity')}
|
|
263
|
+
onChange={(e) => {
|
|
264
|
+
let q = num(e.target.value)
|
|
265
|
+
if (qtyMax != null && q > qtyMax) q = qtyMax
|
|
266
|
+
if (q < 0 && !effectivePolicy.allowNegative) q = 0
|
|
267
|
+
patch(i, { quantity: q })
|
|
268
|
+
}}
|
|
269
|
+
/>
|
|
270
|
+
<Button
|
|
271
|
+
type="button"
|
|
272
|
+
size="icon"
|
|
273
|
+
variant="outline"
|
|
274
|
+
className="size-7"
|
|
275
|
+
disabled={locked || (qtyMax != null && toNum(l.quantity) >= qtyMax)}
|
|
276
|
+
aria-label={t('lineItems.increaseQty', { defaultValue: 'Aumentar cantidad' })}
|
|
277
|
+
onClick={() => {
|
|
278
|
+
const next = toNum(l.quantity) + 1
|
|
279
|
+
patch(i, { quantity: qtyMax != null ? Math.min(next, qtyMax) : next })
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
<Plus className="size-3" />
|
|
283
|
+
</Button>
|
|
284
|
+
</div>
|
|
285
|
+
) : (
|
|
286
|
+
<Input
|
|
287
|
+
inputMode="decimal"
|
|
288
|
+
className="text-right"
|
|
289
|
+
value={String(l.quantity)}
|
|
290
|
+
disabled={locked}
|
|
291
|
+
aria-label={t('lineItems.quantity', { defaultValue: 'Cant.' })}
|
|
292
|
+
aria-invalid={!!cell(i, 'quantity')}
|
|
293
|
+
onChange={(e) => patch(i, { quantity: num(e.target.value) })}
|
|
294
|
+
/>
|
|
295
|
+
)}
|
|
144
296
|
{cell(i, 'quantity') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'quantity')}</p>}
|
|
145
297
|
{!cell(i, 'quantity') && warn(i, 'quantity') && <p className="mt-0.5 text-xs text-muted-foreground">{warn(i, 'quantity')}</p>}
|
|
146
298
|
</td>
|
|
@@ -150,21 +302,42 @@ export function LineItemsEditor({
|
|
|
150
302
|
className="text-right"
|
|
151
303
|
value={String(l.unit_price)}
|
|
152
304
|
disabled={locked}
|
|
305
|
+
aria-label={priceSource === 'cost' ? t('lineItems.unitCost', { defaultValue: 'Costo unitario' }) : t('lineItems.unitPrice', { defaultValue: 'Precio' })}
|
|
153
306
|
aria-invalid={!!cell(i, 'unit_price')}
|
|
154
307
|
onChange={(e) => patch(i, { unit_price: num(e.target.value) })}
|
|
155
308
|
/>
|
|
309
|
+
{l.catalog_price != null && (
|
|
310
|
+
<p className="mt-0.5 text-right text-xs text-muted-foreground">
|
|
311
|
+
{t('lineItems.catalogPrice', { defaultValue: 'Catálogo' })} {fmt.money(l.catalog_price)}
|
|
312
|
+
</p>
|
|
313
|
+
)}
|
|
156
314
|
{cell(i, 'unit_price') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'unit_price')}</p>}
|
|
315
|
+
{!cell(i, 'unit_price') && warn(i, 'unit_price') && <p className="mt-0.5 text-xs text-muted-foreground">{warn(i, 'unit_price')}</p>}
|
|
157
316
|
</td>
|
|
158
317
|
{show('discount') && (
|
|
159
318
|
<td className="px-2 py-1.5">
|
|
160
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
319
|
+
<div className="flex items-center gap-1">
|
|
320
|
+
{discountMode === 'both' && (
|
|
321
|
+
<Button
|
|
322
|
+
type="button"
|
|
323
|
+
size="sm"
|
|
324
|
+
variant="ghost"
|
|
325
|
+
className="h-8 px-1.5"
|
|
326
|
+
aria-label={t('lineItems.discountKind', { defaultValue: 'Tipo de descuento' })}
|
|
327
|
+
onClick={() => patch(i, { discount_kind: kind === 'amount' ? 'percent' : 'amount', discount: 0 })}
|
|
328
|
+
>
|
|
329
|
+
{kind === 'amount' ? '$' : '%'}
|
|
330
|
+
</Button>
|
|
331
|
+
)}
|
|
332
|
+
<Input
|
|
333
|
+
inputMode="decimal"
|
|
334
|
+
className="text-right"
|
|
335
|
+
value={String(l.discount)}
|
|
336
|
+
disabled={locked}
|
|
337
|
+
aria-invalid={!!cell(i, 'discount')}
|
|
338
|
+
onChange={(e) => patch(i, { discount: num(e.target.value), discount_kind: kind })}
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
168
341
|
{cell(i, 'discount') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'discount')}</p>}
|
|
169
342
|
</td>
|
|
170
343
|
)}
|
|
@@ -194,9 +367,9 @@ export function LineItemsEditor({
|
|
|
194
367
|
<Input value={l.dot ?? ''} disabled={locked} onChange={(e) => patch(i, { dot: e.target.value })} />
|
|
195
368
|
</td>
|
|
196
369
|
)}
|
|
197
|
-
<td className="px-2 py-2 text-right tabular-nums">{fmt.money(a.net)}</td>
|
|
370
|
+
<td className="px-2 py-2 text-right tabular-nums" data-slot="line-amount">{fmt.money(a.net)}</td>
|
|
198
371
|
<td className="px-1 py-1.5">
|
|
199
|
-
{
|
|
372
|
+
{canAdd && (
|
|
200
373
|
<Button type="button" size="icon" variant="ghost" onClick={() => remove(i)} aria-label={t('common.delete', { defaultValue: 'Eliminar' })}>
|
|
201
374
|
<Trash2 className="size-4" />
|
|
202
375
|
</Button>
|
|
@@ -205,29 +378,54 @@ export function LineItemsEditor({
|
|
|
205
378
|
</tr>
|
|
206
379
|
)
|
|
207
380
|
})}
|
|
208
|
-
{value.length === 0 && (
|
|
381
|
+
{value.length === 0 && !search && (
|
|
209
382
|
<tr>
|
|
210
383
|
<td colSpan={99} className="px-3 py-6 text-center text-muted-foreground">
|
|
211
384
|
{t('lineItems.empty', { defaultValue: 'Aún no hay renglones. Agrega un producto o un renglón libre.' })}
|
|
212
385
|
</td>
|
|
213
386
|
</tr>
|
|
214
387
|
)}
|
|
388
|
+
{canAdd && search && (
|
|
389
|
+
<SearchRow
|
|
390
|
+
search={search}
|
|
391
|
+
warehouseId={warehouseId}
|
|
392
|
+
currency={currency}
|
|
393
|
+
onPick={pickProduct}
|
|
394
|
+
onAddFree={() => emit([...value, makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {})])}
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
215
397
|
</tbody>
|
|
216
398
|
</table>
|
|
217
399
|
</div>
|
|
218
400
|
|
|
219
401
|
{validation.form && value.length > 0 && <p role="alert" className="text-sm text-destructive">{validation.form}</p>}
|
|
220
402
|
|
|
403
|
+
{undo && canAdd && (
|
|
404
|
+
<p className="text-sm text-muted-foreground">
|
|
405
|
+
{t('lineItems.removed', { defaultValue: 'Renglón eliminado.' })}{' '}
|
|
406
|
+
<button
|
|
407
|
+
type="button"
|
|
408
|
+
className="underline"
|
|
409
|
+
onClick={() => {
|
|
410
|
+
emit(undo)
|
|
411
|
+
setUndo(null)
|
|
412
|
+
}}
|
|
413
|
+
>
|
|
414
|
+
{t('lineItems.undo', { defaultValue: 'Deshacer' })}
|
|
415
|
+
</button>
|
|
416
|
+
</p>
|
|
417
|
+
)}
|
|
418
|
+
|
|
221
419
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
222
|
-
{
|
|
420
|
+
{canAdd ? (
|
|
223
421
|
<div className="flex flex-wrap gap-2">
|
|
224
|
-
{onRequestProduct && (
|
|
422
|
+
{onRequestProduct && !search && (
|
|
225
423
|
<Button type="button" size="sm" variant="outline" onClick={onRequestProduct}>
|
|
226
424
|
<Plus className="mr-1 size-4" />
|
|
227
425
|
{t('lineItems.addProduct', { defaultValue: 'Agregar producto' })}
|
|
228
426
|
</Button>
|
|
229
427
|
)}
|
|
230
|
-
<Button type="button" size="sm" variant="outline" onClick={() => emit([...value, makeLine()])}>
|
|
428
|
+
<Button type="button" size="sm" variant="outline" onClick={() => emit([...value, makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {})])}>
|
|
231
429
|
<Plus className="mr-1 size-4" />
|
|
232
430
|
{t('lineItems.addLine', { defaultValue: 'Renglón libre' })}
|
|
233
431
|
</Button>
|
|
@@ -250,10 +448,131 @@ export function LineItemsEditor({
|
|
|
250
448
|
<dl className="min-w-56 space-y-0.5 text-sm tabular-nums">
|
|
251
449
|
<div className="flex justify-between gap-6"><dt className="text-muted-foreground">{t('lineItems.subtotal', { defaultValue: 'Subtotal' })}</dt><dd>{fmt.money(totals.subtotal)}</dd></div>
|
|
252
450
|
{totals.discount > 0 && <div className="flex justify-between gap-6"><dt className="text-muted-foreground">{t('lineItems.discountTotal', { defaultValue: 'Descuento' })}</dt><dd>-{fmt.money(totals.discount)}</dd></div>}
|
|
253
|
-
{totals.tax > 0 && <div className="flex justify-between gap-6"><dt className="text-muted-foreground">{t('lineItems.taxTotal', { defaultValue: 'Impuestos' })}</dt><dd>{fmt.money(totals.tax)}</dd></div>}
|
|
254
|
-
<div className="flex justify-between gap-6 font-semibold"><dt>{t('lineItems.total', { defaultValue: 'Total' })}</dt><dd>{fmt.money(totals.total)}</dd></div>
|
|
451
|
+
{totals.tax > 0 && <div className="flex justify-between gap-6"><dt className="text-muted-foreground">{t('lineItems.taxTotal', { defaultValue: 'Impuestos' })}</dt><dd data-slot="tax-total">{fmt.money(totals.tax)}</dd></div>}
|
|
452
|
+
<div className="flex justify-between gap-6 font-semibold"><dt>{t('lineItems.total', { defaultValue: 'Total' })}</dt><dd data-slot="grand-total">{fmt.money(totals.total)}</dd></div>
|
|
255
453
|
</dl>
|
|
256
454
|
</div>
|
|
257
455
|
</div>
|
|
258
456
|
)
|
|
259
457
|
}
|
|
458
|
+
|
|
459
|
+
/** Mismo componente. El nombre anterior sigue exportado. */
|
|
460
|
+
export const LineItemsEditor = DocumentLinesGrid
|
|
461
|
+
|
|
462
|
+
function toNum(n: number): number {
|
|
463
|
+
return Number.isFinite(n) ? n : 0
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function SearchRow({
|
|
467
|
+
search,
|
|
468
|
+
warehouseId,
|
|
469
|
+
currency,
|
|
470
|
+
onPick,
|
|
471
|
+
onAddFree,
|
|
472
|
+
}: {
|
|
473
|
+
search: NonNullable<DocumentLinesGridProps['search']>
|
|
474
|
+
warehouseId?: string
|
|
475
|
+
currency?: string
|
|
476
|
+
onPick: (product: ProductResult, variant?: ProductVariant) => void
|
|
477
|
+
onAddFree: () => void
|
|
478
|
+
}) {
|
|
479
|
+
const { t } = useTranslation()
|
|
480
|
+
const fmt = useFormatter({ currency })
|
|
481
|
+
const [text, setText] = useState('')
|
|
482
|
+
const [active, setActive] = useState(0)
|
|
483
|
+
const parsed = parseProductQuery(text)
|
|
484
|
+
const run = useMemo(() => {
|
|
485
|
+
return (q: string, signal: AbortSignal) => {
|
|
486
|
+
const p = parseProductQuery(q)
|
|
487
|
+
return p.kind === 'empty' ? Promise.resolve([]) : search(p, signal)
|
|
488
|
+
}
|
|
489
|
+
}, [search])
|
|
490
|
+
const { results, loading } = useAsyncSearch(text, run, {
|
|
491
|
+
minChars: parsed.kind === 'barcode' ? 8 : 2,
|
|
492
|
+
delay: parsed.kind === 'barcode' ? 0 : 250,
|
|
493
|
+
})
|
|
494
|
+
const flat = results.flatMap((p) =>
|
|
495
|
+
(p.variants?.length ?? 0) > 0 ? p.variants!.map((v) => ({ product: p, variant: v as ProductVariant | undefined })) : [{ product: p, variant: undefined as ProductVariant | undefined }],
|
|
496
|
+
)
|
|
497
|
+
const open = flat.length > 0
|
|
498
|
+
|
|
499
|
+
const choose = (idx: number) => {
|
|
500
|
+
const hit = flat[idx]
|
|
501
|
+
if (!hit) return
|
|
502
|
+
onPick(hit.product, hit.variant)
|
|
503
|
+
setText('')
|
|
504
|
+
setActive(0)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return (
|
|
508
|
+
<tr className="border-t">
|
|
509
|
+
<td colSpan={99} className="px-2 py-1.5">
|
|
510
|
+
<div className="relative">
|
|
511
|
+
<Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden />
|
|
512
|
+
<Input
|
|
513
|
+
className="pl-8"
|
|
514
|
+
value={text}
|
|
515
|
+
role="combobox"
|
|
516
|
+
aria-expanded={open}
|
|
517
|
+
aria-label={t('lineItems.searchProduct', { defaultValue: 'Buscar producto' })}
|
|
518
|
+
placeholder={t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
|
|
519
|
+
onChange={(e) => {
|
|
520
|
+
setText(e.target.value)
|
|
521
|
+
setActive(0)
|
|
522
|
+
}}
|
|
523
|
+
onKeyDown={(e) => {
|
|
524
|
+
if (e.key === 'ArrowDown' && open) {
|
|
525
|
+
e.preventDefault()
|
|
526
|
+
setActive((n) => Math.min(flat.length - 1, n + 1))
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
if (e.key === 'ArrowUp' && open) {
|
|
530
|
+
e.preventDefault()
|
|
531
|
+
setActive((n) => Math.max(0, n - 1))
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
const cmd = lineGridKeyCommand(e.key, { suggestionsOpen: open, cellEmpty: !text.trim(), mode: 'free' })
|
|
535
|
+
if (e.key === 'Enter' && open) {
|
|
536
|
+
e.preventDefault()
|
|
537
|
+
choose(active)
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
if (cmd === 'add' && !text.trim()) {
|
|
541
|
+
e.preventDefault()
|
|
542
|
+
onAddFree()
|
|
543
|
+
}
|
|
544
|
+
}}
|
|
545
|
+
/>
|
|
546
|
+
{open && (
|
|
547
|
+
<ul role="listbox" className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover shadow-md">
|
|
548
|
+
{flat.map((hit, idx) => {
|
|
549
|
+
const price = priceOf(hit.product, hit.variant, warehouseId)
|
|
550
|
+
return (
|
|
551
|
+
<li key={`${hit.product.id}:${hit.variant?.id ?? ''}`} role="option" aria-selected={idx === active}>
|
|
552
|
+
<button
|
|
553
|
+
type="button"
|
|
554
|
+
className={'flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left text-sm hover:bg-accent ' + (idx === active ? 'bg-accent' : '')}
|
|
555
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
556
|
+
onClick={() => choose(idx)}
|
|
557
|
+
>
|
|
558
|
+
<span>
|
|
559
|
+
<span className="block font-medium">{hit.variant ? `${hit.product.name} · ${hit.variant.label}` : hit.product.name}</span>
|
|
560
|
+
<span className="block text-xs text-muted-foreground">{hit.variant?.sku ?? hit.product.sku}</span>
|
|
561
|
+
</span>
|
|
562
|
+
{price != null && <span className="tabular-nums">{fmt.money(price)}</span>}
|
|
563
|
+
</button>
|
|
564
|
+
</li>
|
|
565
|
+
)
|
|
566
|
+
})}
|
|
567
|
+
</ul>
|
|
568
|
+
)}
|
|
569
|
+
{loading && <p className="mt-1 text-xs text-muted-foreground">{t('common.searching', { defaultValue: 'Buscando…' })}</p>}
|
|
570
|
+
</div>
|
|
571
|
+
</td>
|
|
572
|
+
</tr>
|
|
573
|
+
)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function priceOf(p: ProductResult, v: ProductVariant | undefined, _warehouseId?: string): number | undefined {
|
|
577
|
+
return v?.price ?? p.price
|
|
578
|
+
}
|