@asteby/metacore-runtime-react 49.5.0 → 49.7.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 (86) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/business/customer-picker.d.ts +8 -2
  3. package/dist/business/customer-picker.d.ts.map +1 -1
  4. package/dist/business/customer-picker.js +30 -27
  5. package/dist/business/index.d.ts +1 -0
  6. package/dist/business/index.d.ts.map +1 -1
  7. package/dist/business/index.js +1 -0
  8. package/dist/business/line-product-cell.d.ts +8 -10
  9. package/dist/business/line-product-cell.d.ts.map +1 -1
  10. package/dist/business/line-product-cell.js +13 -90
  11. package/dist/business/product-options.d.ts +41 -0
  12. package/dist/business/product-options.d.ts.map +1 -0
  13. package/dist/business/product-options.js +58 -0
  14. package/dist/business/product-picker.d.ts +5 -1
  15. package/dist/business/product-picker.d.ts.map +1 -1
  16. package/dist/business/product-picker.js +26 -35
  17. package/dist/business/relate-documents.d.ts +1 -1
  18. package/dist/business/relate-documents.d.ts.map +1 -1
  19. package/dist/business/relate-documents.js +6 -8
  20. package/dist/business/vehicle-picker.d.ts +8 -2
  21. package/dist/business/vehicle-picker.d.ts.map +1 -1
  22. package/dist/business/vehicle-picker.js +28 -28
  23. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  24. package/dist/dialogs/dynamic-record.js +28 -43
  25. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  26. package/dist/dynamic-multi-select-field.js +36 -6
  27. package/dist/dynamic-select-field.d.ts +9 -23
  28. package/dist/dynamic-select-field.d.ts.map +1 -1
  29. package/dist/dynamic-select-field.js +105 -172
  30. package/dist/entity-select.d.ts +5 -2
  31. package/dist/entity-select.d.ts.map +1 -1
  32. package/dist/entity-select.js +23 -116
  33. package/dist/index.d.ts +9 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +10 -1
  36. package/dist/query-batch.d.ts.map +1 -1
  37. package/dist/query-batch.js +4 -0
  38. package/dist/record-picker-actions.d.ts +40 -2
  39. package/dist/record-picker-actions.d.ts.map +1 -1
  40. package/dist/record-picker-actions.js +44 -4
  41. package/dist/record-picker-dialog.d.ts +23 -0
  42. package/dist/record-picker-dialog.d.ts.map +1 -0
  43. package/dist/record-picker-dialog.js +37 -0
  44. package/dist/record-picker-option.d.ts +25 -0
  45. package/dist/record-picker-option.d.ts.map +1 -0
  46. package/dist/record-picker-option.js +55 -0
  47. package/dist/record-picker.d.ts +149 -0
  48. package/dist/record-picker.d.ts.map +1 -0
  49. package/dist/record-picker.js +372 -0
  50. package/dist/use-option-ids.d.ts +34 -0
  51. package/dist/use-option-ids.d.ts.map +1 -0
  52. package/dist/use-option-ids.js +138 -0
  53. package/dist/use-options-resolver.d.ts +4 -0
  54. package/dist/use-options-resolver.d.ts.map +1 -1
  55. package/dist/use-options-resolver.js +10 -1
  56. package/package.json +1 -1
  57. package/src/__tests__/business-pickers.test.tsx +6 -3
  58. package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
  59. package/src/__tests__/document-form-dialog.test.tsx +4 -3
  60. package/src/__tests__/document-lines-grid.test.tsx +2 -1
  61. package/src/__tests__/edit-field-polish.test.tsx +6 -3
  62. package/src/__tests__/option-ids.test.tsx +181 -0
  63. package/src/__tests__/record-picker.test.tsx +469 -0
  64. package/src/business/customer-picker.tsx +87 -98
  65. package/src/business/index.ts +1 -0
  66. package/src/business/line-product-cell.tsx +55 -180
  67. package/src/business/product-options.tsx +119 -0
  68. package/src/business/product-picker.tsx +63 -126
  69. package/src/business/relate-documents.tsx +36 -45
  70. package/src/business/vehicle-picker.tsx +76 -99
  71. package/src/dialogs/dynamic-record.tsx +44 -97
  72. package/src/dynamic-multi-select-field.tsx +53 -15
  73. package/src/dynamic-select-field.tsx +188 -379
  74. package/src/entity-select.tsx +53 -202
  75. package/src/index.ts +35 -2
  76. package/src/query-batch.ts +3 -0
  77. package/src/record-picker-actions.tsx +101 -8
  78. package/src/record-picker-dialog.tsx +80 -0
  79. package/src/record-picker-option.tsx +96 -0
  80. package/src/record-picker.tsx +971 -0
  81. package/src/use-option-ids.ts +168 -0
  82. package/src/use-options-resolver.ts +11 -1
  83. package/dist/business/use-async-search.d.ts +0 -10
  84. package/dist/business/use-async-search.d.ts.map +0 -1
  85. package/dist/business/use-async-search.js +0 -38
  86. package/src/business/use-async-search.ts +0 -43
@@ -1,16 +1,14 @@
1
1
  // CustomerPicker + alta rápida — busca por nombre, RFC/ID fiscal, teléfono, placa
2
- // o VIN; muestra saldo, crédito y alertas (benchmark §7). El alta rápida reutiliza
2
+ // o VIN; muestra saldo, crédito y alertas (benchmark §7). Configuración del
3
+ // <RecordPicker> compartido: el alta rápida (y la edición del elegido) reutiliza
3
4
  // el <CreateRecordDialog> dinámico del modelo (formulario desde metadata).
4
5
  import { useCallback, useState } from 'react'
5
6
  import { useTranslation } from 'react-i18next'
6
- import { Plus, Search, X } from 'lucide-react'
7
- import { Badge, Button, Input } from '@asteby/metacore-ui'
8
- import { CreateRecordDialog } from '../dialogs/create-record-dialog'
9
- import { useApi } from '../api-context'
7
+ import { Badge } from '@asteby/metacore-ui'
8
+ import { RecordPicker, useLatestSearch } from '../record-picker'
9
+ import { useRecordPickerDialog } from '../record-picker-dialog'
10
10
  import { useCan } from '../permissions-context'
11
11
  import { useFormatter } from './format'
12
- import { useAsyncSearch } from './use-async-search'
13
- import { EmptyState } from './feedback'
14
12
 
15
13
  export interface CustomerResult {
16
14
  id: string
@@ -32,7 +30,7 @@ export interface CustomerPickerProps {
32
30
  value: CustomerResult | null
33
31
  /** Evento único de cambio: cliente elegido/creado, o `null` al limpiar. */
34
32
  onChange: (customer: CustomerResult | null) => void
35
- /** Búsqueda (nombre, ID fiscal, teléfono, placa, VIN). Estable (useCallback). */
33
+ /** Búsqueda (nombre, ID fiscal, teléfono, placa, VIN). */
36
34
  search: (q: string, signal: AbortSignal) => Promise<CustomerResult[]>
37
35
  /** Modelo del kernel para el alta rápida. Default `Customer`. */
38
36
  model?: string
@@ -44,6 +42,8 @@ export interface CustomerPickerProps {
44
42
  validateTaxId?: (taxId: string) => string | undefined
45
43
  /** Overrides de permiso (default `<model>.create` con useCan). */
46
44
  canCreate?: boolean
45
+ /** Override de permiso para editar al elegido (default `<model>.update`). */
46
+ canEdit?: boolean
47
47
  /** Valores iniciales del alta rápida (p. ej. el texto buscado como nombre). */
48
48
  createDefaults?: Record<string, unknown>
49
49
  currency?: string
@@ -55,6 +55,12 @@ function snake(model: string): string {
55
55
  return model.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
56
56
  }
57
57
 
58
+ const customerLine = (c: CustomerResult) => [c.tax_id, c.phone, c.plate, c.vin].filter(Boolean).join(' · ')
59
+
60
+ /**
61
+ * @deprecated Configuración fina de {@link RecordPicker}; se conserva para no
62
+ * romper consumidores.
63
+ */
58
64
  export function CustomerPicker({
59
65
  value,
60
66
  onChange,
@@ -64,26 +70,22 @@ export function CustomerPicker({
64
70
  mapRecord,
65
71
  validateTaxId,
66
72
  canCreate,
73
+ canEdit,
67
74
  createDefaults,
68
75
  currency,
69
76
  disabled,
70
77
  placeholder,
71
78
  }: CustomerPickerProps) {
72
79
  const { t } = useTranslation()
73
- const api = useApi()
74
80
  const can = useCan()
75
81
  const fmt = useFormatter({ currency })
76
82
  const [text, setText] = useState('')
77
- const [creating, setCreating] = useState(false)
78
- const { results, loading, error } = useAsyncSearch(text, search, { minChars: 2 })
83
+ const [open, setOpen] = useState(false)
84
+ const searchFn = useLatestSearch(search)
79
85
  const mayCreate = canCreate ?? can(`${snake(model)}.create`)
80
- const base = endpoint ?? `/data/${model}/me`
86
+ const mayEdit = canEdit ?? can(`${snake(model)}.update`)
81
87
  const taxIdError = value?.tax_id && validateTaxId ? validateTaxId(value.tax_id) : undefined
82
88
 
83
- const pick = (c: CustomerResult) => {
84
- onChange(c)
85
- setText('')
86
- }
87
89
  const toResult = useCallback(
88
90
  (rec: Record<string, unknown>): CustomerResult =>
89
91
  mapRecord
@@ -96,24 +98,23 @@ export function CustomerPicker({
96
98
  },
97
99
  [mapRecord],
98
100
  )
101
+ const { openCreate, openEdit, dialog } = useRecordPickerDialog({
102
+ model,
103
+ endpoint,
104
+ prefillField: 'name',
105
+ createDefaults,
106
+ onSaved: (rec, kind) => {
107
+ const next = toResult(rec)
108
+ // Al editar se conservan saldo/crédito/alertas que el registro no trae.
109
+ onChange(kind === 'edit' && value ? { ...value, ...next } : next)
110
+ setText('')
111
+ },
112
+ })
99
113
 
100
- if (value) {
101
- const overLimit = value.credit_limit != null && (value.balance ?? 0) > value.credit_limit
102
- return (
103
- <div data-slot="customer-picker" className="space-y-1 rounded-md border p-2">
104
- <div className="flex items-start justify-between gap-2">
105
- <div>
106
- <p className="text-sm font-medium">{value.name}</p>
107
- <p className="text-xs text-muted-foreground">
108
- {[value.tax_id, value.phone, value.plate, value.vin].filter(Boolean).join(' · ')}
109
- </p>
110
- </div>
111
- {!disabled && (
112
- <Button type="button" size="icon" variant="ghost" onClick={() => onChange(null)} aria-label={t('customerPicker.clear', { defaultValue: 'Quitar cliente' })}>
113
- <X className="size-4" />
114
- </Button>
115
- )}
116
- </div>
114
+ const overLimit = !!value && value.credit_limit != null && (value.balance ?? 0) > value.credit_limit
115
+ const below = value ? (
116
+ <>
117
+ {(value.balance != null && value.balance !== 0) || value.credit_limit != null || value.alerts?.length ? (
117
118
  <div className="flex flex-wrap gap-1.5">
118
119
  {value.balance != null && value.balance !== 0 && (
119
120
  <Badge variant={overLimit ? 'danger' : 'warning'}>
@@ -129,73 +130,61 @@ export function CustomerPicker({
129
130
  <Badge key={a} variant="danger">{a}</Badge>
130
131
  ))}
131
132
  </div>
132
- {taxIdError && <p role="alert" className="text-xs text-destructive">{taxIdError}</p>}
133
- </div>
134
- )
135
- }
133
+ ) : null}
134
+ {taxIdError && <p role="alert" className="text-xs text-destructive">{taxIdError}</p>}
135
+ </>
136
+ ) : null
136
137
 
137
138
  return (
138
- <div data-slot="customer-picker" className="space-y-2">
139
- <div className="flex items-center gap-1.5">
140
- <div className="relative flex-1">
141
- <Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden />
142
- <Input
143
- className="pl-8"
144
- disabled={disabled}
145
- value={text}
146
- placeholder={placeholder ?? t('customerPicker.placeholder', { defaultValue: 'Nombre, RFC, teléfono, placa o VIN' })}
147
- onChange={(e) => setText(e.target.value)}
148
- />
149
- </div>
150
- {mayCreate && (
151
- <Button type="button" variant="outline" size="icon" disabled={disabled} onClick={() => setCreating(true)} aria-label={t('customerPicker.create', { defaultValue: 'Nuevo cliente' })}>
152
- <Plus className="size-4" />
153
- </Button>
139
+ <>
140
+ <RecordPicker<CustomerResult>
141
+ trigger="input"
142
+ slot="customer-picker"
143
+ search={searchFn}
144
+ minChars={2}
145
+ query={text}
146
+ onQueryChange={setText}
147
+ open={open}
148
+ onOpenChange={setOpen}
149
+ getKey={(c) => c.id}
150
+ getLabel={(c) => c.name}
151
+ renderItem={(c) => (
152
+ <span className="flex w-full min-w-0 items-center justify-between gap-2">
153
+ <span className="min-w-0">
154
+ <span className="block truncate font-medium">{c.name}</span>
155
+ <span className="block truncate text-xs text-muted-foreground">{customerLine(c)}</span>
156
+ </span>
157
+ {c.balance != null && c.balance > 0 && <Badge variant="warning">{fmt.money(c.balance)}</Badge>}
158
+ </span>
154
159
  )}
155
- </div>
156
- {loading && <p className="text-sm text-muted-foreground">{t('common.searching', { defaultValue: 'Buscando…' })}</p>}
157
- {error && !loading && (
158
- <p role="alert" className="text-sm text-destructive">
159
- {t('customerPicker.error', { defaultValue: 'No se pudo buscar clientes. Inténtalo de nuevo.' })}
160
- </p>
161
- )}
162
- {!loading && !error && text.trim().length >= 2 && results.length === 0 && (
163
- <EmptyState
164
- title={t('customerPicker.empty', { defaultValue: 'No encontramos a ese cliente' })}
165
- action={mayCreate ? { label: t('customerPicker.createAction', { defaultValue: 'Crear cliente' }), onClick: () => setCreating(true) } : undefined}
166
- />
167
- )}
168
- {results.length > 0 && (
169
- <ul role="listbox" className="max-h-64 divide-y overflow-auto rounded-md border">
170
- {results.map((c) => (
171
- <li key={c.id} role="option" aria-selected={false}>
172
- <button type="button" className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-accent" onClick={() => pick(c)}>
173
- <span>
174
- <span className="block text-sm font-medium">{c.name}</span>
175
- <span className="block text-xs text-muted-foreground">{[c.tax_id, c.phone, c.plate, c.vin].filter(Boolean).join(' · ')}</span>
176
- </span>
177
- {c.balance != null && c.balance > 0 && <Badge variant="warning">{fmt.money(c.balance)}</Badge>}
178
- </button>
179
- </li>
180
- ))}
181
- </ul>
182
- )}
183
-
184
- {creating && (
185
- <CreateRecordDialog
186
- modelKey={model}
187
- open={creating}
188
- onOpenChange={setCreating}
189
- endpoint={base}
190
- defaults={createDefaults ?? (text.trim() ? { name: text.trim() } : undefined)}
191
- onCreate={async (data) => {
192
- const res = await api.post(base, data)
193
- const rec = (res.data?.data ?? res.data) as Record<string, unknown>
194
- pick(toResult(rec))
195
- return rec.id != null ? { id: String(rec.id) } : undefined
196
- }}
197
- />
198
- )}
199
- </div>
160
+ value={value?.id ?? null}
161
+ selected={value}
162
+ onSelect={(c) => onChange(c)}
163
+ onClear={disabled ? undefined : () => onChange(null)}
164
+ clearLabel={t('customerPicker.clear', { defaultValue: 'Quitar cliente' })}
165
+ renderValue={(c) =>
166
+ c ? (
167
+ <span className="block min-w-0">
168
+ <span className="block truncate text-sm font-medium">{c.name}</span>
169
+ {customerLine(c) && <span className="block truncate text-xs text-muted-foreground">{customerLine(c)}</span>}
170
+ </span>
171
+ ) : null
172
+ }
173
+ below={below}
174
+ disabled={disabled}
175
+ placeholder={placeholder ?? t('customerPicker.placeholder', { defaultValue: 'Nombre, RFC, teléfono, placa o VIN' })}
176
+ loadingText={t('common.searching', { defaultValue: 'Buscando…' })}
177
+ errorText={t('customerPicker.error', { defaultValue: 'No se pudo buscar clientes. Inténtalo de nuevo.' })}
178
+ emptyText={t('customerPicker.empty', { defaultValue: 'No encontramos a ese cliente' })}
179
+ entityLabel={t('customerPicker.entity', { defaultValue: 'cliente' })}
180
+ createLabel={t('customerPicker.create', { defaultValue: 'Nuevo cliente' })}
181
+ editLabel={t('customerPicker.edit', { defaultValue: 'Editar cliente' })}
182
+ createFooter="empty"
183
+ createFooterLabel={() => t('customerPicker.createAction', { defaultValue: 'Crear cliente' })}
184
+ onCreate={mayCreate ? (q) => openCreate(q) : undefined}
185
+ onEdit={mayEdit && value ? () => openEdit(value.id) : undefined}
186
+ />
187
+ {dialog}
188
+ </>
200
189
  )
201
190
  }
@@ -11,6 +11,7 @@ export * from './refund'
11
11
  export * from './refund-destination'
12
12
  export * from './product-search'
13
13
  export * from './product-picker'
14
+ export * from './product-options'
14
15
  export * from './customer-picker'
15
16
  export * from './catalog'
16
17
  export * from './return'
@@ -1,27 +1,30 @@
1
1
  // LineProductCell — buscador de producto DENTRO de la celda «Descripción» de un
2
- // renglón (patrón de editor de facturas pro: Odoo/Holded/Linear). Ocupa solo su
3
- // celda; la lista de resultados va en un portal (Popover de Radix anclado al
4
- // input): nunca la recorta el overflow de la tabla ni del modal, queda por
5
- // encima del diálogo y se abre hacia arriba si abajo no hay espacio.
2
+ // renglón (patrón de editor de facturas pro: Odoo/Holded/Linear). Es una
3
+ // configuración del <RecordPicker> compartido (trigger «input», texto libre,
4
+ // variante celda): ocupa solo su celda y la lista va en un portal — nunca la
5
+ // recorta el overflow de la tabla ni del modal, queda por encima del diálogo y
6
+ // se abre hacia arriba si abajo no hay espacio.
6
7
  //
7
8
  // Teclado: ↑/↓ recorren, Enter elige (el grid pasa el foco a «Cant.»), Esc
8
9
  // cierra, Tab cierra y avanza normal. El texto escrito sin elegir queda como
9
10
  // descripción libre (el renglón libre sigue funcionando igual).
10
- import { forwardRef, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react'
11
+ import { forwardRef, useState, type KeyboardEvent } from 'react'
11
12
  import { useTranslation } from 'react-i18next'
12
- import { Loader2, Search } from 'lucide-react'
13
- import { Badge, Input, Popover, PopoverAnchor, PopoverContent } from '@asteby/metacore-ui/primitives'
14
- import { useFormatter } from './format'
15
- import { useAsyncSearch } from './use-async-search'
16
- import { useDebouncedValue } from '../use-debounced-value'
17
- import { availableStock, parseProductQuery, type ProductQuery, type ProductResult, type ProductVariant } from './product-search'
13
+ import { RecordPicker } from '../record-picker'
14
+ import {
15
+ ProductHitRow,
16
+ productHitKey,
17
+ productHitLabel,
18
+ useProductSearch,
19
+ type ProductHit,
20
+ type ProductSearchFn,
21
+ } from './product-options'
22
+ import type { ProductResult, ProductVariant } from './product-search'
18
23
 
19
- export type LineProductSearch = (query: Exclude<ProductQuery, { kind: 'empty' }>, signal: AbortSignal) => Promise<ProductResult[]>
24
+ export type LineProductSearch = ProductSearchFn
20
25
 
21
- export interface LineProductHit {
22
- product: ProductResult
23
- variant?: ProductVariant
24
- }
26
+ /** @deprecated Alias de {@link ProductHit}. */
27
+ export type LineProductHit = ProductHit
25
28
 
26
29
  export interface LineProductCellProps {
27
30
  search: LineProductSearch
@@ -30,8 +33,9 @@ export interface LineProductCellProps {
30
33
  onTextChange: (text: string) => void
31
34
  onPick: (product: ProductResult, variant?: ProductVariant) => void
32
35
  /**
33
- * Teclas que el buscador no consume (lista cerrada): el grid aplica sus
34
- * comandos (Enter agrega, Supr en celda vacía borra…).
36
+ * Teclas que el buscador no consume (lista cerrada, Enter sin
37
+ * coincidencias…): el grid aplica sus comandos (Enter agrega, Supr en
38
+ * celda vacía borra…).
35
39
  */
36
40
  onKeyDownClosed?: (e: KeyboardEvent<HTMLInputElement>) => void
37
41
  warehouseId?: string
@@ -50,170 +54,41 @@ export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps
50
54
  ref,
51
55
  ) {
52
56
  const { t } = useTranslation()
53
- const fmt = useFormatter({ currency })
54
- const listId = useId()
55
- const anchorRef = useRef<HTMLDivElement>(null)
56
- // Solo se abre mientras se escribe: enfocar un renglón libre ya capturado no
57
- // despliega «sin resultados» encima de la tabla.
57
+ // Solo se busca/abre mientras se escribe: enfocar un renglón libre ya
58
+ // capturado no despliega «sin resultados» encima de la tabla.
58
59
  const [typing, setTyping] = useState(false)
59
- const [active, setActive] = useState(0)
60
- const parsed = parseProductQuery(text)
61
- const run = useMemo(
62
- () => (q: string, signal: AbortSignal) => {
63
- const p = parseProductQuery(q)
64
- return p.kind === 'empty' ? Promise.resolve([]) : search(p, signal)
65
- },
66
- [search],
67
- )
68
- const minChars = parsed.kind === 'barcode' ? 8 : 2
69
- const delay = parsed.kind === 'barcode' ? 0 : 250
70
- const { results, loading: fetching } = useAsyncSearch(typing ? text : '', run, { minChars, delay })
71
- // Mientras el debounce no alcanza lo escrito, la lista dice «Buscando…» y no
72
- // un «sin coincidencias» prematuro.
73
- const settled = useDebouncedValue(text.trim(), delay) === text.trim()
74
- const loading = fetching || !settled
75
- const flat = useMemo<LineProductHit[]>(
76
- () =>
77
- results.flatMap((p) =>
78
- (p.variants?.length ?? 0) > 0 ? p.variants!.map((v) => ({ product: p, variant: v })) : [{ product: p }],
79
- ),
80
- [results],
81
- )
82
- const queryReady = typing && text.trim().length >= minChars
83
- const open = queryReady && (loading || flat.length > 0 || text.trim().length >= 2)
84
- const hasHits = flat.length > 0
85
-
86
- const close = () => {
87
- setTyping(false)
88
- setActive(0)
89
- }
90
- const choose = (idx: number) => {
91
- const hit = flat[idx]
92
- if (!hit) return
93
- close()
94
- onPick(hit.product, hit.variant)
95
- }
96
-
97
- const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
98
- if (open && hasHits) {
99
- if (e.key === 'ArrowDown') {
100
- e.preventDefault()
101
- setActive((n) => Math.min(flat.length - 1, n + 1))
102
- return
103
- }
104
- if (e.key === 'ArrowUp') {
105
- e.preventDefault()
106
- setActive((n) => Math.max(0, n - 1))
107
- return
108
- }
109
- if (e.key === 'Enter') {
110
- e.preventDefault()
111
- choose(active)
112
- return
113
- }
114
- }
115
- if (open && e.key === 'Escape') {
116
- // Cierra la lista sin cerrar el diálogo que la contiene.
117
- e.preventDefault()
118
- e.stopPropagation()
119
- close()
120
- return
121
- }
122
- if (e.key === 'Tab') close()
123
- onKeyDownClosed?.(e)
124
- }
60
+ const { hits, loading, minChars } = useProductSearch(typing ? text : '', search, typing)
125
61
 
126
62
  return (
127
- <Popover open={open} onOpenChange={(o) => !o && close()}>
128
- <PopoverAnchor asChild>
129
- <div ref={anchorRef} className="relative w-full min-w-0" data-slot="line-product-cell">
130
- <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
131
- <Input
132
- ref={ref}
133
- value={text}
134
- disabled={disabled}
135
- role="combobox"
136
- aria-autocomplete="list"
137
- aria-expanded={open}
138
- aria-controls={open ? listId : undefined}
139
- aria-activedescendant={open && hasHits ? `${listId}-${active}` : undefined}
140
- aria-label={ariaLabel}
141
- aria-invalid={invalid || undefined}
142
- autoComplete="off"
143
- data-cell={dataCell}
144
- placeholder={placeholder ?? t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
145
- className={'h-8 pl-8 ' + (className ?? '')}
146
- onChange={(e) => {
147
- onTextChange(e.target.value)
148
- setTyping(true)
149
- setActive(0)
150
- }}
151
- onBlur={() => close()}
152
- onKeyDown={onKeyDown}
153
- />
154
- </div>
155
- </PopoverAnchor>
156
- <PopoverContent
157
- align="start"
158
- sideOffset={4}
159
- collisionPadding={8}
160
- className="p-1"
161
- data-slot="line-product-results"
162
- // El foco se queda en la celda: se sigue escribiendo mientras se ve la lista.
163
- onOpenAutoFocus={(e) => e.preventDefault()}
164
- onCloseAutoFocus={(e) => e.preventDefault()}
165
- onInteractOutside={(e) => {
166
- if (anchorRef.current?.contains(e.target as Node)) e.preventDefault()
167
- }}
168
- style={{ width: 'max(var(--radix-popover-trigger-width), 22rem)', maxWidth: 'calc(100vw - 1rem)' }}
169
- >
170
- {hasHits ? (
171
- <ul id={listId} role="listbox" aria-label={ariaLabel} className="max-h-72 overflow-auto">
172
- {flat.map((hit, idx) => {
173
- const price = hit.variant?.price ?? hit.product.price
174
- const stock = availableStock(hit.variant ?? hit.product, warehouseId)
175
- const meta = [hit.variant?.sku ?? hit.product.sku, hit.product.tire?.normalized].filter(Boolean).join(' · ')
176
- return (
177
- <li key={`${hit.product.id}:${hit.variant?.id ?? ''}`} id={`${listId}-${idx}`} role="option" aria-selected={idx === active}>
178
- <button
179
- type="button"
180
- tabIndex={-1}
181
- className={
182
- 'flex w-full items-center justify-between gap-3 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent hover:text-accent-foreground ' +
183
- (idx === active ? 'bg-accent text-accent-foreground' : '')
184
- }
185
- onMouseDown={(e) => e.preventDefault()}
186
- onMouseEnter={() => setActive(idx)}
187
- onClick={() => choose(idx)}
188
- >
189
- <span className="min-w-0">
190
- <span className="block truncate font-medium">{hit.variant ? `${hit.product.name} · ${hit.variant.label}` : hit.product.name}</span>
191
- {meta && <span className="block truncate text-xs text-muted-foreground">{meta}</span>}
192
- </span>
193
- <span className="flex shrink-0 items-center gap-2">
194
- {stock != null && (
195
- <Badge variant="secondary" className="font-normal tabular-nums">
196
- {stock}
197
- </Badge>
198
- )}
199
- {price != null && <span className="tabular-nums">{fmt.money(price)}</span>}
200
- </span>
201
- </button>
202
- </li>
203
- )
204
- })}
205
- </ul>
206
- ) : loading ? (
207
- <p className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
208
- <Loader2 className="size-4 animate-spin" aria-hidden />
209
- {t('common.searching', { defaultValue: 'Buscando…' })}
210
- </p>
211
- ) : (
212
- <p className="px-2 py-3 text-sm text-muted-foreground">
213
- {t('lineItems.noProduct', { defaultValue: 'Sin coincidencias. Se usará como descripción libre.' })}
214
- </p>
215
- )}
216
- </PopoverContent>
217
- </Popover>
63
+ <RecordPicker<ProductHit>
64
+ trigger="input"
65
+ variant="cell"
66
+ freeText
67
+ anchorSlot="line-product-cell"
68
+ contentSlot="line-product-results"
69
+ minListWidth="22rem"
70
+ items={hits}
71
+ loading={loading}
72
+ minChars={minChars}
73
+ query={text}
74
+ onQueryChange={onTextChange}
75
+ open={typing}
76
+ onOpenChange={setTyping}
77
+ getKey={productHitKey}
78
+ getLabel={productHitLabel}
79
+ renderItem={(hit) => <ProductHitRow hit={hit} warehouseId={warehouseId} currency={currency} compact />}
80
+ onSelect={(hit) => onPick(hit.product, hit.variant)}
81
+ onUnhandledKeyDown={onKeyDownClosed}
82
+ inputRef={ref}
83
+ ariaLabel={ariaLabel}
84
+ listLabel={ariaLabel}
85
+ disabled={disabled}
86
+ invalid={invalid}
87
+ triggerClassName={className}
88
+ triggerProps={{ 'data-cell': dataCell }}
89
+ placeholder={placeholder ?? t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
90
+ loadingText={t('common.searching', { defaultValue: 'Buscando…' })}
91
+ emptyText={t('lineItems.noProduct', { defaultValue: 'Sin coincidencias. Se usará como descripción libre.' })}
92
+ />
218
93
  )
219
94
  })
@@ -0,0 +1,119 @@
1
+ // Product options for <RecordPicker>: one search (products, tires, variants)
2
+ // and one option row (name · variant, SKU / tire size / supplier code, stock and
3
+ // price on the right) shared by ProductPicker and the DocumentEditor's
4
+ // LineProductCell — so both product pickers are configurations of the same
5
+ // primitive instead of two hand-rolled lists.
6
+ import { useMemo } from 'react'
7
+ import { useTranslation } from 'react-i18next'
8
+ import { Badge } from '@asteby/metacore-ui/primitives'
9
+ import { useLatestSearch, useRecordSearch } from '../record-picker'
10
+ import { useFormatter } from './format'
11
+ import {
12
+ availableStock,
13
+ parseProductQuery,
14
+ type ProductQuery,
15
+ type ProductResult,
16
+ type ProductVariant,
17
+ } from './product-search'
18
+
19
+ /** A pickable row: a product, or one concrete variant of it. */
20
+ export interface ProductHit {
21
+ product: ProductResult
22
+ variant?: ProductVariant
23
+ }
24
+
25
+ export type ProductSearchFn = (
26
+ query: Exclude<ProductQuery, { kind: 'empty' }>,
27
+ signal: AbortSignal,
28
+ ) => Promise<ProductResult[]>
29
+
30
+ /** Products with variants expand to one row per variant. */
31
+ export function flattenProductHits(results: readonly ProductResult[]): ProductHit[] {
32
+ return results.flatMap((p) =>
33
+ (p.variants?.length ?? 0) > 0 ? p.variants!.map((v) => ({ product: p, variant: v })) : [{ product: p }],
34
+ )
35
+ }
36
+
37
+ export const productHitKey = (h: ProductHit): string => `${h.product.id}:${h.variant?.id ?? ''}`
38
+ export const productHitLabel = (h: ProductHit): string =>
39
+ h.variant ? `${h.product.name} · ${h.variant.label}` : h.product.name
40
+ export const productHitMeta = (h: ProductHit): string =>
41
+ [
42
+ h.variant?.sku ?? h.product.sku,
43
+ h.product.tire?.normalized,
44
+ h.product.supplier_sku && `Prov. ${h.product.supplier_sku}`,
45
+ ]
46
+ .filter(Boolean)
47
+ .join(' · ')
48
+
49
+ /**
50
+ * Classified product search: a barcode is exact (8+ digits, no debounce), the
51
+ * rest waits for 2 characters and 250ms. `search` needn't be memoized.
52
+ */
53
+ export function useProductSearch(text: string, search: ProductSearchFn, enabled = true) {
54
+ const stable = useLatestSearch(search)
55
+ const parsed = parseProductQuery(text)
56
+ const run = useMemo(
57
+ () => (q: string, signal: AbortSignal) => {
58
+ const p = parseProductQuery(q)
59
+ return p.kind === 'empty' ? Promise.resolve([] as ProductResult[]) : stable(p, signal)
60
+ },
61
+ [stable],
62
+ )
63
+ const minChars = parsed.kind === 'barcode' ? 8 : 2
64
+ const delay = parsed.kind === 'barcode' ? 0 : 250
65
+ const { results, loading, error, pending } = useRecordSearch(text, run, { minChars, delay, enabled })
66
+ const hits = useMemo(() => flattenProductHits(results), [results])
67
+ return { parsed, results, hits, loading: loading || pending, error, minChars }
68
+ }
69
+
70
+ /** Stock + price at the right of a product row. `compact` → bare stock count (table cell). */
71
+ export function ProductHitTrailing({
72
+ hit,
73
+ warehouseId,
74
+ currency,
75
+ compact = false,
76
+ }: {
77
+ hit: ProductHit
78
+ warehouseId?: string
79
+ currency?: string
80
+ compact?: boolean
81
+ }) {
82
+ const { t } = useTranslation()
83
+ const fmt = useFormatter({ currency })
84
+ const price = hit.variant?.price ?? hit.product.price
85
+ const stock = availableStock(hit.variant ?? hit.product, warehouseId)
86
+ return (
87
+ <>
88
+ {stock != null &&
89
+ (compact ? (
90
+ <Badge variant="secondary" className="font-normal tabular-nums">
91
+ {stock}
92
+ </Badge>
93
+ ) : (
94
+ <Badge variant={stock > 0 ? 'success' : 'danger'}>
95
+ {stock > 0
96
+ ? t('productPicker.inStock', { defaultValue: '{{n}} disp.', n: stock })
97
+ : t('productPicker.outOfStock', { defaultValue: 'Sin existencia' })}
98
+ </Badge>
99
+ ))}
100
+ {price != null && <span className="tabular-nums">{fmt.money(price)}</span>}
101
+ </>
102
+ )
103
+ }
104
+
105
+ /** Option body: bold name (· variant), muted meta line, stock/price right. */
106
+ export function ProductHitRow({ hit, warehouseId, currency, compact }: { hit: ProductHit; warehouseId?: string; currency?: string; compact?: boolean }) {
107
+ const meta = productHitMeta(hit)
108
+ return (
109
+ <span className="flex w-full min-w-0 items-center justify-between gap-3">
110
+ <span className="min-w-0">
111
+ <span className="block truncate font-medium">{productHitLabel(hit)}</span>
112
+ {meta && <span className="block truncate text-xs text-muted-foreground">{meta}</span>}
113
+ </span>
114
+ <span className="flex shrink-0 items-center gap-2">
115
+ <ProductHitTrailing hit={hit} warehouseId={warehouseId} currency={currency} compact={compact} />
116
+ </span>
117
+ </span>
118
+ )
119
+ }