@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/business/document-editor-model.d.ts +27 -2
  3. package/dist/business/document-editor-model.d.ts.map +1 -1
  4. package/dist/business/document-editor-model.js +53 -3
  5. package/dist/business/document-editor.d.ts +7 -2
  6. package/dist/business/document-editor.d.ts.map +1 -1
  7. package/dist/business/document-editor.js +116 -21
  8. package/dist/business/document-lines.d.ts +21 -1
  9. package/dist/business/document-lines.d.ts.map +1 -1
  10. package/dist/business/document-lines.js +69 -7
  11. package/dist/business/line-items-editor.d.ts +21 -1
  12. package/dist/business/line-items-editor.d.ts.map +1 -1
  13. package/dist/business/line-items-editor.js +74 -15
  14. package/dist/business/line-items.d.ts +28 -0
  15. package/dist/business/line-items.d.ts.map +1 -1
  16. package/dist/business/line-items.js +1 -0
  17. package/dist/business/line-product-cell.d.ts +6 -0
  18. package/dist/business/line-product-cell.d.ts.map +1 -1
  19. package/dist/business/line-product-cell.js +9 -3
  20. package/dist/business/product-options.js +1 -1
  21. package/dist/dialogs/dynamic-record.js +1 -1
  22. package/dist/field-grid.d.ts +4 -3
  23. package/dist/field-grid.d.ts.map +1 -1
  24. package/dist/field-grid.js +1 -1
  25. package/dist/record-picker.d.ts +2 -0
  26. package/dist/record-picker.d.ts.map +1 -1
  27. package/dist/record-picker.js +11 -4
  28. package/package.json +1 -1
  29. package/src/__tests__/document-editor-inline-selects.test.tsx +11 -4
  30. package/src/__tests__/line-row-create-edit.test.tsx +312 -0
  31. package/src/business/document-editor-model.ts +60 -2
  32. package/src/business/document-editor.tsx +132 -17
  33. package/src/business/document-lines.ts +79 -7
  34. package/src/business/line-items-editor.tsx +158 -30
  35. package/src/business/line-items.ts +31 -0
  36. package/src/business/line-product-cell.tsx +32 -5
  37. package/src/business/product-options.tsx +1 -1
  38. package/src/dialogs/dynamic-record.tsx +1 -1
  39. package/src/field-grid.tsx +5 -4
  40. 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
- ...line,
39
+ ...rest,
38
40
  kind: 'item',
39
41
  product_id: variant?.id ?? product.id,
40
- sku: variant?.sku ?? product.sku,
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(value, product, variant, { priceSource, warehouseId, mergeSameProduct })
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 && !value.includes(l)) ?? next[next.length - 1]
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 = value[idx]
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(value.map((l, i) => (i === idx ? (discountMode === 'amount' ? { ...filled, discount_kind: 'amount' as const } : filled) : l)))
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
- <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
- />
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
- trailingCells={colCount - 1}
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
- trailingCells,
674
+ onCreate,
675
+ entityLabel,
676
+ span,
565
677
  }: {
566
- trailingCells: number
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
- <td className={CELL}>
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
- {/* 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
- ))}
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="22rem"
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.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
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={t('lineItems.noProduct', { defaultValue: 'Sin coincidencias. Se usará como descripción libre.' })}
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="block truncate font-medium">{productHitLabel(hit)}</span>
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-xs font-medium text-muted-foreground uppercase tracking-wide">
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>
@@ -42,15 +42,16 @@ export function FieldCell({
42
42
  export function FieldLabel({
43
43
  htmlFor,
44
44
  required,
45
- tone = 'caps',
45
+ tone = 'sentence',
46
46
  children,
47
47
  }: {
48
48
  htmlFor?: string
49
49
  required?: boolean
50
50
  /**
51
- * `caps` (default): small uppercase eyebrow of the CRUD modals. `sentence`:
52
- * sentence-case label for dense editors (DocumentEditor) where a column of
53
- * uppercase labels reads as shouting.
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
@@ -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 className="max-h-72 overflow-y-auto overflow-x-hidden overscroll-contain" data-slot="record-picker-list">
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="truncate">{getLabel(item)}</span>
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: `max(var(--radix-popover-trigger-width), ${minListWidth})`,
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
  }