@asteby/metacore-runtime-react 49.3.1 → 49.4.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 (41) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/business/document-editor-panels.d.ts +2 -1
  3. package/dist/business/document-editor-panels.d.ts.map +1 -1
  4. package/dist/business/document-editor-panels.js +6 -6
  5. package/dist/business/document-editor.d.ts.map +1 -1
  6. package/dist/business/document-editor.js +23 -9
  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 +6 -1
  11. package/dist/business/line-items-editor.d.ts.map +1 -1
  12. package/dist/business/line-items-editor.js +109 -83
  13. package/dist/business/line-product-cell.d.ts +32 -0
  14. package/dist/business/line-product-cell.d.ts.map +1 -0
  15. package/dist/business/line-product-cell.js +100 -0
  16. package/dist/dynamic-select-field.d.ts +2 -1
  17. package/dist/dynamic-select-field.d.ts.map +1 -1
  18. package/dist/dynamic-select-field.js +49 -19
  19. package/dist/entity-select.d.ts.map +1 -1
  20. package/dist/entity-select.js +13 -11
  21. package/dist/field-grid.d.ts +7 -1
  22. package/dist/field-grid.d.ts.map +1 -1
  23. package/dist/field-grid.js +4 -2
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -0
  27. package/dist/record-picker-actions.d.ts +27 -0
  28. package/dist/record-picker-actions.d.ts.map +1 -0
  29. package/dist/record-picker-actions.js +32 -0
  30. package/package.json +1 -1
  31. package/src/__tests__/document-editor-inline-selects.test.tsx +202 -0
  32. package/src/business/document-editor-panels.tsx +13 -10
  33. package/src/business/document-editor.tsx +72 -20
  34. package/src/business/index.ts +1 -0
  35. package/src/business/line-items-editor.tsx +229 -174
  36. package/src/business/line-product-cell.tsx +219 -0
  37. package/src/dynamic-select-field.tsx +51 -22
  38. package/src/entity-select.tsx +17 -39
  39. package/src/field-grid.tsx +15 -1
  40. package/src/index.ts +2 -0
  41. package/src/record-picker-actions.tsx +81 -0
@@ -1,14 +1,15 @@
1
1
  // DocumentLinesGrid — editor único de renglones (UX-2).
2
2
  // `LineItemsEditor` es el mismo componente: las props nuevas son opcionales.
3
3
  // Controlado: `value`/`onChange` con `LineItem[]`. Guardar = `serializeLineItems`.
4
- import { useMemo, useState } from 'react'
4
+ import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
5
5
  import { useTranslation } from 'react-i18next'
6
- import { Heading, Minus, Plus, Search, StickyNote, Trash2 } from 'lucide-react'
6
+ import { Heading, Minus, 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
+ applyProductToLine,
12
13
  lineGridKeyCommand,
13
14
  type DocumentLinesMode,
14
15
  type PriceSource,
@@ -22,9 +23,8 @@ import {
22
23
  type LineItemsPolicy,
23
24
  type LineItemsValidation,
24
25
  } from './line-items'
25
- import { useAsyncSearch } from './use-async-search'
26
+ import { LineProductCell } from './line-product-cell'
26
27
  import {
27
- parseProductQuery,
28
28
  type ProductQuery,
29
29
  type ProductResult,
30
30
  type ProductVariant,
@@ -68,6 +68,11 @@ export interface DocumentLinesGridProps {
68
68
  editPermission?: string
69
69
  /** Moneda ISO (default: la de la org). */
70
70
  currency?: string
71
+ /**
72
+ * Pinta el bloque de totales bajo la tabla. Default true; un contenedor que
73
+ * ya muestra sus propios totales (DocumentEditor) lo apaga.
74
+ */
75
+ showTotals?: boolean
71
76
  }
72
77
 
73
78
  /** @deprecated Usa `DocumentLinesGridProps`. Es el mismo contrato. */
@@ -94,6 +99,7 @@ export function DocumentLinesGrid({
94
99
  readOnly = false,
95
100
  editPermission,
96
101
  currency,
102
+ showTotals = true,
97
103
  }: DocumentLinesGridProps) {
98
104
  const { t } = useTranslation()
99
105
  const can = useCan()
@@ -136,52 +142,121 @@ export function DocumentLinesGrid({
136
142
  const cell = (i: number, field: keyof LineItem) => errors[`${i}.${field}`]
137
143
  const warn = (i: number, field: string) => validation.warnings[`${i}.${field}`]
138
144
 
145
+ // Foco tras elegir: Enter en el buscador pasa a «Cant.» del renglón que
146
+ // recibió el producto (nuevo o sumado), como un editor de facturas pro.
147
+ const rootRef = useRef<HTMLDivElement>(null)
148
+ const [focusQty, setFocusQty] = useState<string | null>(null)
149
+ useEffect(() => {
150
+ if (!focusQty) return
151
+ const el = rootRef.current?.querySelector<HTMLInputElement>(`[data-line-key="${focusQty}"] [data-cell="quantity"]`)
152
+ if (el) {
153
+ el.focus()
154
+ el.select?.()
155
+ }
156
+ setFocusQty(null)
157
+ }, [focusQty, value])
158
+
159
+ const blankLine = () => makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {})
160
+
161
+ /** Renglón vacío del final: el producto entra como renglón nuevo (o suma al mismo). */
139
162
  const pickProduct = (product: ProductResult, variant?: ProductVariant) => {
140
- emit(addProductLine(value, product, variant, { priceSource, warehouseId, mergeSameProduct }))
163
+ const next = addProductLine(value, product, variant, { priceSource, warehouseId, mergeSameProduct })
164
+ emit(next)
165
+ const id = variant?.id ?? product.id
166
+ const target = next.find((l) => l.kind === 'item' && l.product_id === id && !value.includes(l)) ?? next[next.length - 1]
167
+ if (target) setFocusQty(target.key)
168
+ }
169
+
170
+ /** 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 = value[idx]
173
+ if (!cur) return
174
+ const filled = applyProductToLine(cur, product, variant, { priceSource, warehouseId })
175
+ emit(value.map((l, i) => (i === idx ? (discountMode === 'amount' ? { ...filled, discount_kind: 'amount' as const } : filled) : l)))
176
+ setFocusQty(cur.key)
177
+ }
178
+
179
+ /** Texto escrito en el renglón vacío sin elegir producto → renglón libre con esa descripción. */
180
+ const addFreeFromDraft = (description: string) => {
181
+ const line = { ...blankLine(), description }
182
+ emit([...value, line])
183
+ setFocusQty(line.key)
184
+ }
185
+
186
+ // Ancho mínimo = columnas fijas + un mínimo legible para «Descripción».
187
+ const minWidth =
188
+ 260 +
189
+ (stepper ? 144 : 96) +
190
+ 112 +
191
+ 112 +
192
+ 40 +
193
+ (show('discount') ? (discountMode === 'both' ? 128 : 96) : 0) +
194
+ (show('tax') ? 64 : 0) +
195
+ (show('unit') ? 80 : 0) +
196
+ (show('technician') ? 128 : 0) +
197
+ (show('lot') ? 112 : 0) +
198
+ (show('dot') ? 96 : 0)
199
+ const colCount = 1 + (['unit', 'discount', 'tax', 'technician', 'lot', 'dot'] as LineItemsColumn[]).filter(show).length + 4
200
+
201
+ const descKeys = (e: KeyboardEvent<HTMLInputElement>, i: number, l: LineItem) => {
202
+ const cmd = lineGridKeyCommand(e.key, {
203
+ cellEmpty: !l.description.trim() && !l.product_id,
204
+ mode,
205
+ })
206
+ if (cmd === 'delete') {
207
+ e.preventDefault()
208
+ remove(i)
209
+ } else if (cmd === 'add') {
210
+ e.preventDefault()
211
+ emit([...value, blankLine()])
212
+ }
141
213
  }
142
214
 
143
215
  return (
144
- <div data-slot="line-items-editor" data-component="DocumentLinesGrid" data-mode={mode} className="space-y-2">
145
- <div className="overflow-x-auto rounded-md border">
146
- <table className="w-full text-sm">
147
- <thead className="bg-muted/50 text-left text-xs text-muted-foreground">
216
+ <div ref={rootRef} data-slot="line-items-editor" data-component="DocumentLinesGrid" data-mode={mode} className="space-y-2">
217
+ <div className="overflow-x-auto rounded-lg border bg-card/40">
218
+ {/* table-fixed: «Descripción» se queda con el ancho libre y las
219
+ numéricas con el suyo; en pantallas angostas la tabla
220
+ conserva un mínimo y desplaza en horizontal. */}
221
+ <table className="w-full table-fixed border-collapse text-sm" style={{ minWidth }}>
222
+ <thead className="border-b bg-muted/40 text-left text-xs font-medium text-muted-foreground">
148
223
  <tr>
149
- <th className="px-2 py-2">{t('lineItems.description', { defaultValue: 'Descripción' })}</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">
224
+ <th className="px-3 py-2 font-medium">{t('lineItems.description', { defaultValue: 'Descripción' })}</th>
225
+ {show('unit') && <th className="w-20 px-3 py-2 font-medium">{t('lineItems.unit', { defaultValue: 'Unidad' })}</th>}
226
+ <th className={(stepper ? 'w-36' : 'w-24') + ' px-3 py-2 text-right font-medium'}>{t('lineItems.quantity', { defaultValue: 'Cant.' })}</th>
227
+ <th className="w-28 px-3 py-2 text-right font-medium">
153
228
  {priceSource === 'cost'
154
229
  ? t('lineItems.unitCost', { defaultValue: 'Costo unitario' })
155
230
  : t('lineItems.unitPrice', { defaultValue: 'Precio' })}
156
231
  </th>
157
232
  {show('discount') && (
158
- <th className="w-28 px-2 py-2 text-right">
233
+ <th className={(discountMode === 'both' ? 'w-32' : 'w-24') + ' px-3 py-2 text-right font-medium'}>
159
234
  {discountMode === 'amount'
160
235
  ? t('lineItems.discountAmount', { defaultValue: 'Desc.' })
161
236
  : t('lineItems.discount', { defaultValue: 'Desc. %' })}
162
237
  </th>
163
238
  )}
164
- {show('tax') && <th className="w-20 px-2 py-2 text-right">{t('lineItems.tax', { defaultValue: 'IVA %' })}</th>}
165
- {show('technician') && <th className="w-32 px-2 py-2">{t('lineItems.technician', { defaultValue: 'Técnico' })}</th>}
166
- {show('lot') && <th className="w-28 px-2 py-2">{t('lineItems.lot', { defaultValue: 'Lote' })}</th>}
167
- {show('dot') && <th className="w-24 px-2 py-2">DOT</th>}
168
- <th className="w-32 px-2 py-2 text-right">{t('lineItems.amount', { defaultValue: 'Importe' })}</th>
169
- <th className="w-10" />
239
+ {show('tax') && <th className="w-16 px-3 py-2 text-right font-medium">{t('lineItems.tax', { defaultValue: 'IVA %' })}</th>}
240
+ {show('technician') && <th className="w-32 px-3 py-2 font-medium">{t('lineItems.technician', { defaultValue: 'Técnico' })}</th>}
241
+ {show('lot') && <th className="w-28 px-3 py-2 font-medium">{t('lineItems.lot', { defaultValue: 'Lote' })}</th>}
242
+ {show('dot') && <th className="w-24 px-3 py-2 font-medium">DOT</th>}
243
+ <th className="w-28 px-3 py-2 text-right font-medium">{t('lineItems.amount', { defaultValue: 'Importe' })}</th>
244
+ <th className="w-10"><span className="sr-only">{t('common.actions', { defaultValue: 'Acciones' })}</span></th>
170
245
  </tr>
171
246
  </thead>
172
247
  <tbody>
173
248
  {value.map((l, i) => {
174
249
  if (l.kind !== 'item') {
175
250
  return (
176
- <tr key={l.key} className="border-t bg-muted/30">
177
- <td colSpan={99} className="px-2 py-1.5">
251
+ <tr key={l.key} className="group border-t bg-muted/30" data-line-key={l.key}>
252
+ <td colSpan={99} className={CELL}>
178
253
  <div className="flex items-center gap-2">
179
254
  <Input
180
255
  value={l.description}
181
256
  disabled={locked}
182
257
  aria-invalid={!!cell(i, 'description')}
183
258
  placeholder={l.kind === 'section' ? t('lineItems.sectionPlaceholder', { defaultValue: 'Sección' }) : t('lineItems.notePlaceholder', { defaultValue: 'Nota' })}
184
- className={l.kind === 'section' ? 'font-semibold' : 'italic'}
259
+ className={'h-8 ' + (l.kind === 'section' ? 'font-semibold' : 'italic')}
185
260
  onChange={(e) => patch(i, { description: e.target.value })}
186
261
  onKeyDown={(e) => {
187
262
  const cmd = lineGridKeyCommand(e.key, { cellEmpty: !l.description.trim(), mode })
@@ -192,7 +267,7 @@ export function DocumentLinesGrid({
192
267
  }}
193
268
  />
194
269
  {canAdd && (
195
- <Button type="button" size="icon" variant="ghost" onClick={() => remove(i)} aria-label={t('common.delete', { defaultValue: 'Eliminar' })}>
270
+ <Button type="button" size="icon" variant="ghost" className={DELETE} onClick={() => remove(i)} aria-label={t('common.delete', { defaultValue: 'Eliminar' })}>
196
271
  <Trash2 className="size-4" />
197
272
  </Button>
198
273
  )}
@@ -207,39 +282,44 @@ export function DocumentLinesGrid({
207
282
  const kind = shown.discount_kind ?? 'percent'
208
283
  const qtyMax = l.max_quantity
209
284
  return (
210
- <tr key={l.key} className="border-t align-top" data-line-key={l.key}>
211
- <td className="px-2 py-1.5">
212
- <Input
213
- value={l.description}
214
- disabled={locked}
215
- aria-label={t('lineItems.description', { defaultValue: 'Descripción' })}
216
- aria-invalid={!!cell(i, 'description')}
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
- }}
231
- />
285
+ <tr key={l.key} className="group border-t align-top transition-colors hover:bg-muted/30 focus-within:bg-muted/20" data-line-key={l.key}>
286
+ <td className={CELL}>
287
+ {search && canAdd && !l.product_id ? (
288
+ <LineProductCell
289
+ search={search}
290
+ text={l.description}
291
+ onTextChange={(text) => patch(i, { description: text })}
292
+ onPick={(p, v) => fillLine(i, p, v)}
293
+ onKeyDownClosed={(e) => descKeys(e, i, l)}
294
+ warehouseId={warehouseId}
295
+ currency={currency}
296
+ ariaLabel={t('lineItems.description', { defaultValue: 'Descripción' })}
297
+ invalid={!!cell(i, 'description')}
298
+ dataCell="description"
299
+ />
300
+ ) : (
301
+ <Input
302
+ value={l.description}
303
+ disabled={locked}
304
+ className={INPUT}
305
+ data-cell="description"
306
+ aria-label={t('lineItems.description', { defaultValue: 'Descripción' })}
307
+ aria-invalid={!!cell(i, 'description')}
308
+ onChange={(e) => patch(i, { description: e.target.value })}
309
+ onKeyDown={(e) => descKeys(e, i, l)}
310
+ />
311
+ )}
232
312
  {(l.sku || l.unit) && (
233
313
  <p className="mt-0.5 text-xs text-muted-foreground">{[l.sku, !show('unit') ? l.unit : ''].filter(Boolean).join(' · ')}</p>
234
314
  )}
235
315
  {cell(i, 'description') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'description')}</p>}
236
316
  </td>
237
317
  {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 })} />
318
+ <td className={CELL}>
319
+ <Input className={INPUT} value={l.unit ?? ''} disabled={locked} onChange={(e) => patch(i, { unit: e.target.value })} />
240
320
  </td>
241
321
  )}
242
- <td className="px-2 py-1.5">
322
+ <td className={CELL}>
243
323
  {stepper ? (
244
324
  <div className="flex items-center justify-end gap-1">
245
325
  <Button
@@ -255,7 +335,8 @@ export function DocumentLinesGrid({
255
335
  </Button>
256
336
  <Input
257
337
  inputMode="decimal"
258
- className="h-8 w-14 text-right"
338
+ className="h-8 w-14 text-right tabular-nums"
339
+ data-cell="quantity"
259
340
  value={String(l.quantity)}
260
341
  disabled={locked}
261
342
  aria-label={t('lineItems.quantity', { defaultValue: 'Cant.' })}
@@ -285,7 +366,8 @@ export function DocumentLinesGrid({
285
366
  ) : (
286
367
  <Input
287
368
  inputMode="decimal"
288
- className="text-right"
369
+ className={NUM}
370
+ data-cell="quantity"
289
371
  value={String(l.quantity)}
290
372
  disabled={locked}
291
373
  aria-label={t('lineItems.quantity', { defaultValue: 'Cant.' })}
@@ -296,10 +378,10 @@ export function DocumentLinesGrid({
296
378
  {cell(i, 'quantity') && <p className="mt-0.5 text-xs text-destructive">{cell(i, 'quantity')}</p>}
297
379
  {!cell(i, 'quantity') && warn(i, 'quantity') && <p className="mt-0.5 text-xs text-muted-foreground">{warn(i, 'quantity')}</p>}
298
380
  </td>
299
- <td className="px-2 py-1.5">
381
+ <td className={CELL}>
300
382
  <Input
301
383
  inputMode="decimal"
302
- className="text-right"
384
+ className={NUM}
303
385
  value={String(l.unit_price)}
304
386
  disabled={locked}
305
387
  aria-label={priceSource === 'cost' ? t('lineItems.unitCost', { defaultValue: 'Costo unitario' }) : t('lineItems.unitPrice', { defaultValue: 'Precio' })}
@@ -307,7 +389,7 @@ export function DocumentLinesGrid({
307
389
  onChange={(e) => patch(i, { unit_price: num(e.target.value) })}
308
390
  />
309
391
  {l.catalog_price != null && (
310
- <p className="mt-0.5 text-right text-xs text-muted-foreground">
392
+ <p className="mt-0.5 truncate whitespace-nowrap text-right text-xs text-muted-foreground">
311
393
  {t('lineItems.catalogPrice', { defaultValue: 'Catálogo' })} {fmt.money(l.catalog_price)}
312
394
  </p>
313
395
  )}
@@ -315,7 +397,7 @@ export function DocumentLinesGrid({
315
397
  {!cell(i, 'unit_price') && warn(i, 'unit_price') && <p className="mt-0.5 text-xs text-muted-foreground">{warn(i, 'unit_price')}</p>}
316
398
  </td>
317
399
  {show('discount') && (
318
- <td className="px-2 py-1.5">
400
+ <td className={CELL}>
319
401
  <div className="flex items-center gap-1">
320
402
  {discountMode === 'both' && (
321
403
  <Button
@@ -331,7 +413,7 @@ export function DocumentLinesGrid({
331
413
  )}
332
414
  <Input
333
415
  inputMode="decimal"
334
- className="text-right"
416
+ className={NUM}
335
417
  value={String(l.discount)}
336
418
  disabled={locked}
337
419
  aria-invalid={!!cell(i, 'discount')}
@@ -342,10 +424,10 @@ export function DocumentLinesGrid({
342
424
  </td>
343
425
  )}
344
426
  {show('tax') && (
345
- <td className="px-2 py-1.5">
427
+ <td className={CELL}>
346
428
  <Input
347
429
  inputMode="decimal"
348
- className="text-right"
430
+ className={NUM}
349
431
  value={String(Math.round(l.tax_rate * 10000) / 100)}
350
432
  disabled={locked}
351
433
  onChange={(e) => patch(i, { tax_rate: num(e.target.value) / 100 })}
@@ -353,24 +435,26 @@ export function DocumentLinesGrid({
353
435
  </td>
354
436
  )}
355
437
  {show('technician') && (
356
- <td className="px-2 py-1.5">
357
- <Input value={l.technician_id ?? ''} disabled={locked} onChange={(e) => patch(i, { technician_id: e.target.value })} />
438
+ <td className={CELL}>
439
+ <Input className={INPUT} value={l.technician_id ?? ''} disabled={locked} onChange={(e) => patch(i, { technician_id: e.target.value })} />
358
440
  </td>
359
441
  )}
360
442
  {show('lot') && (
361
- <td className="px-2 py-1.5">
362
- <Input value={l.lot ?? ''} disabled={locked} onChange={(e) => patch(i, { lot: e.target.value })} />
443
+ <td className={CELL}>
444
+ <Input className={INPUT} value={l.lot ?? ''} disabled={locked} onChange={(e) => patch(i, { lot: e.target.value })} />
363
445
  </td>
364
446
  )}
365
447
  {show('dot') && (
366
- <td className="px-2 py-1.5">
367
- <Input value={l.dot ?? ''} disabled={locked} onChange={(e) => patch(i, { dot: e.target.value })} />
448
+ <td className={CELL}>
449
+ <Input className={INPUT} value={l.dot ?? ''} disabled={locked} onChange={(e) => patch(i, { dot: e.target.value })} />
368
450
  </td>
369
451
  )}
370
- <td className="px-2 py-2 text-right tabular-nums" data-slot="line-amount">{fmt.money(a.net)}</td>
371
- <td className="px-1 py-1.5">
452
+ <td className="px-3 py-1.5 text-right align-top font-medium tabular-nums" data-slot="line-amount">
453
+ <span className="inline-flex h-8 items-center">{fmt.money(a.net)}</span>
454
+ </td>
455
+ <td className="px-1 py-1.5 align-top">
372
456
  {canAdd && (
373
- <Button type="button" size="icon" variant="ghost" onClick={() => remove(i)} aria-label={t('common.delete', { defaultValue: 'Eliminar' })}>
457
+ <Button type="button" size="icon" variant="ghost" className={DELETE} onClick={() => remove(i)} aria-label={t('common.delete', { defaultValue: 'Eliminar' })}>
374
458
  <Trash2 className="size-4" />
375
459
  </Button>
376
460
  )}
@@ -386,15 +470,25 @@ export function DocumentLinesGrid({
386
470
  </tr>
387
471
  )}
388
472
  {canAdd && search && (
389
- <SearchRow
473
+ <DraftRow
390
474
  search={search}
391
475
  warehouseId={warehouseId}
392
476
  currency={currency}
393
477
  onPick={pickProduct}
394
- onAddFree={() => emit([...value, makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {})])}
478
+ onFree={addFreeFromDraft}
479
+ onAddBlank={() => emit([...value, blankLine()])}
480
+ trailingCells={colCount - 1}
395
481
  />
396
482
  )}
397
483
  </tbody>
484
+ {showTotals && (
485
+ <tfoot className="border-t bg-muted/20 text-sm tabular-nums" data-slot="line-totals">
486
+ <TotalRow span={colCount - 2} label={t('lineItems.subtotal', { defaultValue: 'Subtotal' })} value={fmt.money(totals.subtotal)} />
487
+ {totals.discount > 0 && <TotalRow span={colCount - 2} label={t('lineItems.discountTotal', { defaultValue: 'Descuento' })} value={`-${fmt.money(totals.discount)}`} />}
488
+ {totals.tax > 0 && <TotalRow span={colCount - 2} label={t('lineItems.taxTotal', { defaultValue: 'Impuestos' })} value={fmt.money(totals.tax)} slot="tax-total" />}
489
+ <TotalRow span={colCount - 2} label={t('lineItems.total', { defaultValue: 'Total' })} value={fmt.money(totals.total)} slot="grand-total" strong />
490
+ </tfoot>
491
+ )}
398
492
  </table>
399
493
  </div>
400
494
 
@@ -416,16 +510,16 @@ export function DocumentLinesGrid({
416
510
  </p>
417
511
  )}
418
512
 
419
- <div className="flex flex-wrap items-start justify-between gap-3">
513
+ <div className="flex flex-wrap items-center gap-3">
420
514
  {canAdd ? (
421
- <div className="flex flex-wrap gap-2">
515
+ <div className="flex flex-wrap gap-1">
422
516
  {onRequestProduct && !search && (
423
517
  <Button type="button" size="sm" variant="outline" onClick={onRequestProduct}>
424
518
  <Plus className="mr-1 size-4" />
425
519
  {t('lineItems.addProduct', { defaultValue: 'Agregar producto' })}
426
520
  </Button>
427
521
  )}
428
- <Button type="button" size="sm" variant="outline" onClick={() => emit([...value, makeLine(discountMode === 'amount' ? { discount_kind: 'amount' } : {})])}>
522
+ <Button type="button" size="sm" variant="ghost" onClick={() => emit([...value, blankLine()])}>
429
523
  <Plus className="mr-1 size-4" />
430
524
  {t('lineItems.addLine', { defaultValue: 'Renglón libre' })}
431
525
  </Button>
@@ -442,15 +536,7 @@ export function DocumentLinesGrid({
442
536
  </>
443
537
  )}
444
538
  </div>
445
- ) : (
446
- <span />
447
- )}
448
- <dl className="min-w-56 space-y-0.5 text-sm tabular-nums">
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>
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>}
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>
453
- </dl>
539
+ ) : null}
454
540
  </div>
455
541
  </div>
456
542
  )
@@ -463,116 +549,85 @@ function toNum(n: number): number {
463
549
  return Number.isFinite(n) ? n : 0
464
550
  }
465
551
 
466
- function SearchRow({
552
+ /**
553
+ * Renglón vacío del final, siempre listo para buscar: el buscador vive en la
554
+ * celda «Descripción» (no en una barra a todo lo ancho). Elegir agrega el
555
+ * producto; Enter con texto sin coincidencias lo agrega como renglón libre.
556
+ */
557
+ function DraftRow({
467
558
  search,
468
559
  warehouseId,
469
560
  currency,
470
561
  onPick,
471
- onAddFree,
562
+ onFree,
563
+ onAddBlank,
564
+ trailingCells,
472
565
  }: {
566
+ trailingCells: number
473
567
  search: NonNullable<DocumentLinesGridProps['search']>
474
568
  warehouseId?: string
475
569
  currency?: string
476
570
  onPick: (product: ProductResult, variant?: ProductVariant) => void
477
- onAddFree: () => void
571
+ onFree: (description: string) => void
572
+ onAddBlank: () => void
478
573
  }) {
479
574
  const { t } = useTranslation()
480
- const fmt = useFormatter({ currency })
481
575
  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 }],
576
+ return (
577
+ <tr className="border-t border-dashed" data-slot="line-draft-row">
578
+ <td className={CELL}>
579
+ <LineProductCell
580
+ search={search}
581
+ text={text}
582
+ onTextChange={setText}
583
+ onPick={(p, v) => {
584
+ setText('')
585
+ onPick(p, v)
586
+ }}
587
+ onKeyDownClosed={(e) => {
588
+ if (e.key !== 'Enter') return
589
+ e.preventDefault()
590
+ const typed = text.trim()
591
+ setText('')
592
+ if (typed) onFree(typed)
593
+ else onAddBlank()
594
+ }}
595
+ warehouseId={warehouseId}
596
+ currency={currency}
597
+ ariaLabel={t('lineItems.searchProduct', { defaultValue: 'Buscar producto' })}
598
+ placeholder={t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
599
+ className="border-dashed bg-transparent shadow-none"
600
+ dataCell="search"
601
+ />
602
+ </td>
603
+ {/* Celdas vacías con la misma estructura: el buscador mide lo que su columna. */}
604
+ {Array.from({ length: trailingCells }, (_, i) => (
605
+ <td key={i} aria-hidden />
606
+ ))}
607
+ </tr>
496
608
  )
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
- }
609
+ }
506
610
 
611
+ /** Fila de totales alineada a la columna «Importe». */
612
+ function TotalRow({ span, label, value, slot, strong }: { span: number; label: string; value: string; slot?: string; strong?: boolean }) {
507
613
  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>
614
+ <tr className={strong ? 'font-semibold' : 'text-muted-foreground'}>
615
+ <td colSpan={span} className="px-3 py-1 text-right">{label}</td>
616
+ <td className={'px-3 py-1 text-right ' + (strong ? 'text-base text-foreground' : 'text-foreground')} data-slot={slot}>
617
+ {value}
571
618
  </td>
619
+ <td />
572
620
  </tr>
573
621
  )
574
622
  }
575
623
 
576
- function priceOf(p: ProductResult, v: ProductVariant | undefined, _warehouseId?: string): number | undefined {
577
- return v?.price ?? p.price
578
- }
624
+ /** Borrar renglón: aparece al pasar o enfocar la fila (siempre visible en móvil). */
625
+ const DELETE =
626
+ '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
+
628
+ /** Celda densa: misma altura en todos los renglones. */
629
+ const CELL = 'px-2 py-1.5 align-top'
630
+ /** Inputs de celda: un poco más bajos que los del formulario (densidad de editor). */
631
+ const INPUT = 'h-8'
632
+ /** Numéricos: alineados a la derecha y con cifras tabulares. */
633
+ const NUM = 'h-8 text-right tabular-nums'