@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/business/catalog.d.ts.map +1 -1
  3. package/dist/business/catalog.js +16 -4
  4. package/dist/business/document-lines.d.ts +30 -0
  5. package/dist/business/document-lines.d.ts.map +1 -0
  6. package/dist/business/document-lines.js +65 -0
  7. package/dist/business/index.d.ts +1 -0
  8. package/dist/business/index.d.ts.map +1 -1
  9. package/dist/business/index.js +1 -0
  10. package/dist/business/line-items-editor.d.ts +28 -4
  11. package/dist/business/line-items-editor.d.ts.map +1 -1
  12. package/dist/business/line-items-editor.js +139 -14
  13. package/dist/business/line-items.d.ts +24 -2
  14. package/dist/business/line-items.d.ts.map +1 -1
  15. package/dist/business/line-items.js +50 -5
  16. package/dist/business/product-search.d.ts +4 -0
  17. package/dist/business/product-search.d.ts.map +1 -1
  18. package/dist/business/product-search.js +5 -1
  19. package/dist/dynamic-form-schema.d.ts +9 -0
  20. package/dist/dynamic-form-schema.d.ts.map +1 -1
  21. package/dist/dynamic-form-schema.js +61 -0
  22. package/dist/dynamic-line-items.d.ts.map +1 -1
  23. package/dist/dynamic-line-items.js +18 -8
  24. package/dist/dynamic-select-field.d.ts +3 -1
  25. package/dist/dynamic-select-field.d.ts.map +1 -1
  26. package/dist/dynamic-select-field.js +2 -1
  27. package/dist/use-options-resolver.d.ts +5 -5
  28. package/dist/use-options-resolver.d.ts.map +1 -1
  29. package/dist/use-options-resolver.js +11 -0
  30. package/package.json +1 -1
  31. package/src/__tests__/document-lines-grid.test.tsx +79 -0
  32. package/src/__tests__/line-item-formulas.test.ts +18 -1
  33. package/src/__tests__/use-options-resolver.test.ts +5 -0
  34. package/src/business/business.test.ts +43 -0
  35. package/src/business/catalog.ts +16 -4
  36. package/src/business/document-lines.ts +91 -0
  37. package/src/business/index.ts +1 -0
  38. package/src/business/line-items-editor.tsx +365 -46
  39. package/src/business/line-items.ts +65 -7
  40. package/src/business/product-search.ts +9 -1
  41. package/src/dynamic-form-schema.ts +60 -0
  42. package/src/dynamic-line-items.tsx +23 -3
  43. package/src/dynamic-select-field.tsx +4 -0
  44. package/src/use-options-resolver.ts +15 -0
@@ -1,11 +1,18 @@
1
- // LineItemsEditor — editor de renglones compartido (benchmark §7, COT-1/PIT-018).
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, Trash2, StickyNote, Heading } from 'lucide-react'
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 LineItemsEditorProps {
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; al elegir, el llamador hace `onChange([...value, makeLine(...)])`. */
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 LineItemsEditor({
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
- }: LineItemsEditorProps) {
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 validation = useMemo(() => validateLineItems(value, policy), [value, policy])
63
- const totals = useMemo(() => computeTotals(value), [value])
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
- onValidate?.(validateLineItems(next, policy))
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
- const remove = (idx: number) => emit(value.filter((_, i) => i !== idx))
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-24 px-2 py-2 text-right">{t('lineItems.quantity', { defaultValue: 'Cant.' })}</th>
86
- <th className="w-32 px-2 py-2 text-right">{t('lineItems.unitPrice', { defaultValue: 'Precio' })}</th>
87
- {show('discount') && <th className="w-20 px-2 py-2 text-right">{t('lineItems.discount', { defaultValue: 'Desc. %' })}</th>}
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.total', { defaultValue: 'Importe' })}</th>
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
- {!locked && (
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 a = computeLine(l)
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 && <p className="mt-0.5 text-xs text-muted-foreground">{l.sku}</p>}
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
- <Input
137
- inputMode="decimal"
138
- className="text-right"
139
- value={String(l.quantity)}
140
- disabled={locked}
141
- aria-invalid={!!cell(i, 'quantity')}
142
- onChange={(e) => patch(i, { quantity: num(e.target.value) })}
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
- <Input
161
- inputMode="decimal"
162
- className="text-right"
163
- value={String(l.discount)}
164
- disabled={locked}
165
- aria-invalid={!!cell(i, 'discount')}
166
- onChange={(e) => patch(i, { discount: num(e.target.value) })}
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
- {!locked && (
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
- {!locked ? (
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
+ }