@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,24 +1,17 @@
1
1
  // ProductPicker — una sola búsqueda para productos, llantas y variantes
2
2
  // (medida «205/55R16», SKU, código de barras, clave de proveedor). Benchmark §7.
3
- import { useCallback, useState } from 'react'
3
+ // Configuración del <RecordPicker> compartido (mismas filas que la celda de
4
+ // producto del DocumentEditor, ver product-options.tsx).
5
+ import { useState } from 'react'
4
6
  import { useTranslation } from 'react-i18next'
5
- import { Search } from 'lucide-react'
6
- import { Badge, Input } from '@asteby/metacore-ui'
7
- import { useFormatter } from './format'
8
- import { useAsyncSearch } from './use-async-search'
9
- import {
10
- availableStock,
11
- parseProductQuery,
12
- type ProductQuery,
13
- type ProductResult,
14
- type ProductVariant,
15
- } from './product-search'
16
- import { EmptyState } from './feedback'
7
+ import { RecordPicker } from '../record-picker'
8
+ import { ProductHitRow, productHitKey, productHitLabel, useProductSearch, type ProductHit } from './product-options'
9
+ import { availableStock, type ProductQuery, type ProductResult, type ProductVariant } from './product-search'
17
10
 
18
11
  export interface ProductPickerProps {
19
12
  /**
20
13
  * Búsqueda: recibe la consulta ya clasificada (`barcode` | `tire_size` |
21
- * `text`) para que el backend elija el índice. Debe ser estable (useCallback).
14
+ * `text`) para que el backend elija el índice.
22
15
  */
23
16
  search: (query: Exclude<ProductQuery, { kind: 'empty' }>, signal: AbortSignal) => Promise<ProductResult[]>
24
17
  /** Se emite al elegir un producto o una variante concreta. */
@@ -35,6 +28,10 @@ export interface ProductPickerProps {
35
28
  disabled?: boolean
36
29
  }
37
30
 
31
+ /**
32
+ * @deprecated Configuración fina de {@link RecordPicker}; para pantallas nuevas
33
+ * usa RecordPicker con `useProductSearch` / `ProductHitRow`.
34
+ */
38
35
  export function ProductPicker({
39
36
  search,
40
37
  onSelect,
@@ -47,125 +44,65 @@ export function ProductPicker({
47
44
  disabled,
48
45
  }: ProductPickerProps) {
49
46
  const { t } = useTranslation()
50
- const fmt = useFormatter({ currency })
51
47
  const [text, setText] = useState('')
52
- const parsed = parseProductQuery(text)
53
- // Un código de barras es exacto: no se espera a 2 caracteres ni se debouncea mucho.
54
- const run = useCallback(
55
- (q: string, signal: AbortSignal) => {
56
- const p = parseProductQuery(q)
57
- return p.kind === 'empty' ? Promise.resolve([]) : search(p, signal)
58
- },
59
- [search],
60
- )
61
- const { results, loading, error } = useAsyncSearch(text, run, {
62
- minChars: parsed.kind === 'barcode' ? 8 : 2,
63
- delay: parsed.kind === 'barcode' ? 0 : 250,
64
- })
48
+ const [open, setOpen] = useState(false)
49
+ const { parsed, results, hits, loading, error, minChars } = useProductSearch(text, search)
65
50
 
51
+ const outOfStock = (h: ProductHit) => {
52
+ const stock = availableStock(h.variant ?? h.product, warehouseId)
53
+ return !allowOutOfStock && stock != null && stock <= 0
54
+ }
66
55
  const pick = (p: ProductResult, v?: ProductVariant) => {
67
- const stock = availableStock(v ?? p, warehouseId)
68
- if (!allowOutOfStock && stock != null && stock <= 0) return
56
+ if (outOfStock({ product: p, variant: v })) return
69
57
  onSelect(p, v)
70
58
  setText('')
71
59
  }
72
60
 
73
61
  return (
74
- <div data-slot="product-picker" className="space-y-2">
75
- <div className="relative">
76
- <Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden />
77
- <Input
78
- autoFocus={autoFocus}
79
- disabled={disabled}
80
- className="pl-8"
81
- value={text}
82
- placeholder={placeholder ?? t('productPicker.placeholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
83
- onChange={(e) => setText(e.target.value)}
84
- onKeyDown={(e) => {
85
- if (e.key === 'Enter' && autoSelectBarcode && parsed.kind === 'barcode' && results.length === 1) {
86
- e.preventDefault()
87
- const only = results[0]!
88
- const exact = only.variants?.find((v) => v.barcode === parsed.barcode)
89
- pick(only, exact)
90
- }
91
- }}
92
- />
93
- </div>
94
-
95
- {parsed.kind === 'tire_size' && (
96
- <p className="text-xs text-muted-foreground">
97
- {t('productPicker.tireSize', { defaultValue: 'Medida de llanta' })}: {parsed.tire.normalized}
98
- </p>
99
- )}
100
- {loading && <p className="text-sm text-muted-foreground">{t('common.searching', { defaultValue: 'Buscando…' })}</p>}
101
- {error && !loading && (
102
- <p role="alert" className="text-sm text-destructive">
103
- {t('productPicker.error', { defaultValue: 'No se pudo buscar. Revisa tu conexión e inténtalo de nuevo.' })}
104
- </p>
105
- )}
106
- {!loading && !error && parsed.kind !== 'empty' && text.trim().length >= 2 && results.length === 0 && (
107
- <EmptyState
108
- title={t('productPicker.empty', { defaultValue: 'No encontramos ese producto' })}
109
- description={t('productPicker.emptyHint', { defaultValue: 'Prueba con otra medida, SKU o código de barras.' })}
110
- />
111
- )}
112
-
113
- {results.length > 0 && (
114
- <ul role="listbox" className="max-h-72 divide-y overflow-auto rounded-md border">
115
- {results.map((p) => {
116
- const stock = availableStock(p, warehouseId)
117
- const hasVariants = (p.variants?.length ?? 0) > 0
118
- return (
119
- <li key={p.id} role="option" aria-selected={false} className="px-2 py-1.5">
120
- <button
121
- type="button"
122
- className="flex w-full items-center justify-between gap-2 text-left disabled:opacity-50"
123
- disabled={hasVariants || (!allowOutOfStock && stock != null && stock <= 0)}
124
- onClick={() => pick(p)}
125
- >
126
- <span>
127
- <span className="block text-sm font-medium">{p.name}</span>
128
- <span className="block text-xs text-muted-foreground">
129
- {[p.sku, p.tire?.normalized, p.supplier_sku && `Prov. ${p.supplier_sku}`].filter(Boolean).join(' · ')}
130
- </span>
131
- </span>
132
- <span className="flex items-center gap-2">
133
- {stock != null && (
134
- <Badge variant={stock > 0 ? 'success' : 'danger'}>
135
- {stock > 0 ? t('productPicker.inStock', { defaultValue: '{{n}} disp.', n: stock }) : t('productPicker.outOfStock', { defaultValue: 'Sin existencia' })}
136
- </Badge>
137
- )}
138
- {p.price != null && !hasVariants && <span className="text-sm tabular-nums">{fmt.money(p.price)}</span>}
139
- </span>
140
- </button>
141
- {hasVariants && (
142
- <ul className="mt-1 space-y-0.5 pl-3">
143
- {p.variants!.map((v) => {
144
- const vs = availableStock(v, warehouseId)
145
- return (
146
- <li key={v.id}>
147
- <button
148
- type="button"
149
- className="flex w-full items-center justify-between gap-2 rounded px-1 py-0.5 text-left text-sm hover:bg-accent disabled:opacity-50"
150
- disabled={!allowOutOfStock && vs != null && vs <= 0}
151
- onClick={() => pick(p, v)}
152
- >
153
- <span>{v.label}</span>
154
- <span className="flex items-center gap-2">
155
- {vs != null && <Badge variant={vs > 0 ? 'success' : 'danger'}>{vs}</Badge>}
156
- {(v.price ?? p.price) != null && <span className="tabular-nums">{fmt.money(v.price ?? p.price)}</span>}
157
- </span>
158
- </button>
159
- </li>
160
- )
161
- })}
162
- </ul>
163
- )}
164
- </li>
165
- )
166
- })}
167
- </ul>
168
- )}
169
- </div>
62
+ <RecordPicker<ProductHit>
63
+ trigger="input"
64
+ slot="product-picker"
65
+ items={hits}
66
+ loading={loading}
67
+ error={error}
68
+ minChars={minChars}
69
+ query={text}
70
+ onQueryChange={setText}
71
+ open={open}
72
+ onOpenChange={setOpen}
73
+ getKey={productHitKey}
74
+ getLabel={productHitLabel}
75
+ renderItem={(hit) => <ProductHitRow hit={hit} warehouseId={warehouseId} currency={currency} />}
76
+ isItemDisabled={outOfStock}
77
+ onSelect={(hit) => pick(hit.product, hit.variant)}
78
+ onInputKeyDown={(e) => {
79
+ // Código de barras exacto con un único resultado: Enter lo elige.
80
+ if (e.key === 'Enter' && autoSelectBarcode && parsed.kind === 'barcode' && results.length === 1) {
81
+ e.preventDefault()
82
+ const only = results[0]!
83
+ pick(only, only.variants?.find((v) => v.barcode === parsed.barcode))
84
+ setOpen(false)
85
+ }
86
+ }}
87
+ disabled={disabled}
88
+ triggerProps={{ autoFocus }}
89
+ minListWidth="22rem"
90
+ placeholder={placeholder ?? t('productPicker.placeholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
91
+ loadingText={t('common.searching', { defaultValue: 'Buscando…' })}
92
+ errorText={t('productPicker.error', { defaultValue: 'No se pudo buscar. Revisa tu conexión e inténtalo de nuevo.' })}
93
+ emptyText={
94
+ <span className="block space-y-0.5">
95
+ <span className="block font-medium text-foreground">{t('productPicker.empty', { defaultValue: 'No encontramos ese producto' })}</span>
96
+ <span className="block text-xs">{t('productPicker.emptyHint', { defaultValue: 'Prueba con otra medida, SKU o código de barras.' })}</span>
97
+ </span>
98
+ }
99
+ below={
100
+ parsed.kind === 'tire_size' ? (
101
+ <p className="text-xs text-muted-foreground">
102
+ {t('productPicker.tireSize', { defaultValue: 'Medida de llanta' })}: {parsed.tire.normalized}
103
+ </p>
104
+ ) : null
105
+ }
106
+ />
170
107
  )
171
108
  }
@@ -5,9 +5,9 @@
5
5
  // (p. ej. SAT 01/03/04) llegan por la prop `relationTypes`.
6
6
  import { useState } from 'react'
7
7
  import { useTranslation } from 'react-i18next'
8
- import { Link2, Search, X } from 'lucide-react'
9
- import { Badge, Button, Input } from '@asteby/metacore-ui'
10
- import { useAsyncSearch } from './use-async-search'
8
+ import { Link2, X } from 'lucide-react'
9
+ import { Badge, Button } from '@asteby/metacore-ui'
10
+ import { RecordPicker, useLatestSearch } from '../record-picker'
11
11
 
12
12
  export interface RelatableDocument {
13
13
  id: string
@@ -40,7 +40,7 @@ export interface RelateDocumentsProps {
40
40
  onChange: (value: RelatedDocument[]) => void
41
41
  /** Catálogo de tipos de relación (lo aporta el addon). */
42
42
  relationTypes: RelationTypeOption[]
43
- /** Búsqueda de documentos origen. Estable (useCallback). */
43
+ /** Búsqueda de documentos origen. */
44
44
  search: (q: string, signal: AbortSignal) => Promise<RelatableDocument[]>
45
45
  /** Tipo preseleccionado al agregar. Default: el primero del catálogo. */
46
46
  defaultRelationType?: string
@@ -66,8 +66,9 @@ export function RelateDocuments({
66
66
  }: RelateDocumentsProps) {
67
67
  const { t } = useTranslation()
68
68
  const [text, setText] = useState('')
69
+ const [open, setOpen] = useState(false)
69
70
  const [pendingType, setPendingType] = useState<string>(defaultRelationType ?? relationTypes[0]?.value ?? '')
70
- const { results, loading, error } = useAsyncSearch(text, search, { minChars: 2 })
71
+ const searchFn = useLatestSearch(search)
71
72
  const full = max != null && value.length >= max
72
73
  const typeLabel = (v: string) => relationTypes.find((r) => r.value === v)?.label ?? v
73
74
 
@@ -121,17 +122,34 @@ export function RelateDocuments({
121
122
  )}
122
123
 
123
124
  {!full && !disabled && (
124
- <div className="space-y-2">
125
- <div className="flex items-center gap-1.5">
126
- <div className="relative flex-1">
127
- <Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden />
128
- <Input
129
- className="pl-8"
130
- value={text}
131
- placeholder={placeholder ?? t('relateDocuments.placeholder', { defaultValue: 'Buscar por folio o UUID' })}
132
- onChange={(e) => setText(e.target.value)}
133
- />
134
- </div>
125
+ // La búsqueda de documentos es el mismo <RecordPicker> de toda la
126
+ // plataforma (lista en portal, teclado, estados).
127
+ <RecordPicker<RelatableDocument>
128
+ trigger="input"
129
+ search={searchFn}
130
+ minChars={2}
131
+ query={text}
132
+ onQueryChange={setText}
133
+ open={open}
134
+ onOpenChange={setOpen}
135
+ getKey={(d) => d.id}
136
+ getLabel={docTitle}
137
+ isItemDisabled={(d) => value.some((x) => x.related_document_id === d.id)}
138
+ renderItem={(d, { disabled: already }) => (
139
+ <span className="flex w-full min-w-0 items-center justify-between gap-2">
140
+ <span className="min-w-0">
141
+ <span className="block truncate text-sm font-medium">{docTitle(d)}</span>
142
+ {d.uuid && <span className="block break-all font-mono text-xs text-muted-foreground">{d.uuid}</span>}
143
+ </span>
144
+ {already && <Badge variant="muted">{t('relateDocuments.added', { defaultValue: 'Agregado' })}</Badge>}
145
+ </span>
146
+ )}
147
+ onSelect={add}
148
+ placeholder={placeholder ?? t('relateDocuments.placeholder', { defaultValue: 'Buscar por folio o UUID' })}
149
+ loadingText={t('common.searching', { defaultValue: 'Buscando…' })}
150
+ errorText={t('relateDocuments.error', { defaultValue: 'No se pudo buscar documentos. Inténtalo de nuevo.' })}
151
+ emptyText={t('relateDocuments.empty', { defaultValue: 'No encontramos ese documento.' })}
152
+ after={
135
153
  <select
136
154
  className={selectCls}
137
155
  value={pendingType}
@@ -142,35 +160,8 @@ export function RelateDocuments({
142
160
  <option key={r.value} value={r.value}>{r.label}</option>
143
161
  ))}
144
162
  </select>
145
- </div>
146
- {loading && <p className="text-sm text-muted-foreground">{t('common.searching', { defaultValue: 'Buscando…' })}</p>}
147
- {error && !loading && (
148
- <p role="alert" className="text-sm text-destructive">
149
- {t('relateDocuments.error', { defaultValue: 'No se pudo buscar documentos. Inténtalo de nuevo.' })}
150
- </p>
151
- )}
152
- {!loading && !error && text.trim().length >= 2 && results.length === 0 && (
153
- <p className="text-sm text-muted-foreground">{t('relateDocuments.empty', { defaultValue: 'No encontramos ese documento.' })}</p>
154
- )}
155
- {results.length > 0 && (
156
- <ul role="listbox" className="max-h-64 divide-y overflow-auto rounded-md border">
157
- {results.map((d) => {
158
- const already = value.some((x) => x.related_document_id === d.id)
159
- return (
160
- <li key={d.id} role="option" aria-selected={already}>
161
- <button type="button" disabled={already} className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-accent disabled:opacity-50" onClick={() => add(d)}>
162
- <span>
163
- <span className="block text-sm font-medium">{docTitle(d)}</span>
164
- {d.uuid && <span className="block break-all font-mono text-xs text-muted-foreground">{d.uuid}</span>}
165
- </span>
166
- {already && <Badge variant="muted">{t('relateDocuments.added', { defaultValue: 'Agregado' })}</Badge>}
167
- </button>
168
- </li>
169
- )
170
- })}
171
- </ul>
172
- )}
173
- </div>
163
+ }
164
+ />
174
165
  )}
175
166
  </div>
176
167
  )
@@ -1,17 +1,15 @@
1
1
  // VehiclePicker + alta rápida — busca por placa o VIN (y marca/modelo/cliente que
2
- // el host decida indexar) y reutiliza el <CreateRecordDialog> dinámico del modelo
3
- // vehículo para el alta rápida. Mismo contrato que <CustomerPicker>: controlado
2
+ // el host decida indexar). Configuración del <RecordPicker> compartido: el alta
3
+ // rápida (y la edición del elegido) reutiliza el <CreateRecordDialog> dinámico
4
+ // del modelo vehículo. Mismo contrato que <CustomerPicker>: controlado
4
5
  // (`value` / `onChange`), `search` inyectado por el host, permisos con useCan
5
6
  // (sin <PermissionsProvider> no bloquea nada: el backend autoriza).
6
7
  import { useCallback, useState } from 'react'
7
8
  import { useTranslation } from 'react-i18next'
8
- import { Car, Plus, Search, X } from 'lucide-react'
9
- import { Button, Input } from '@asteby/metacore-ui'
10
- import { CreateRecordDialog } from '../dialogs/create-record-dialog'
11
- import { useApi } from '../api-context'
9
+ import { Car } from 'lucide-react'
10
+ import { RecordPicker, useLatestSearch } from '../record-picker'
11
+ import { useRecordPickerDialog } from '../record-picker-dialog'
12
12
  import { useCan } from '../permissions-context'
13
- import { useAsyncSearch } from './use-async-search'
14
- import { EmptyState } from './feedback'
15
13
 
16
14
  export interface VehicleResult {
17
15
  id: string
@@ -31,7 +29,7 @@ export interface VehiclePickerProps {
31
29
  value: VehicleResult | null
32
30
  /** Evento único de cambio: vehículo elegido/creado, o `null` al limpiar. */
33
31
  onChange: (vehicle: VehicleResult | null) => void
34
- /** Búsqueda por placa o VIN. Estable (useCallback). */
32
+ /** Búsqueda por placa o VIN. */
35
33
  search: (q: string, signal: AbortSignal) => Promise<VehicleResult[]>
36
34
  /** Modelo del kernel para el alta rápida. Default `Vehicle`. */
37
35
  model?: string
@@ -41,6 +39,8 @@ export interface VehiclePickerProps {
41
39
  mapRecord?: (rec: Record<string, unknown>) => VehicleResult
42
40
  /** Override de permiso (default `<model>.create` con useCan). */
43
41
  canCreate?: boolean
42
+ /** Override de permiso para editar al elegido (default `<model>.update`). */
43
+ canEdit?: boolean
44
44
  /** Valores iniciales del alta (p. ej. `customer_id` del cliente ya elegido). */
45
45
  createDefaults?: Record<string, unknown>
46
46
  disabled?: boolean
@@ -59,6 +59,10 @@ export function describeVehicle(v: VehicleResult): string {
59
59
  return [unit, v.plate, v.vin].filter(Boolean).join(' · ')
60
60
  }
61
61
 
62
+ /**
63
+ * @deprecated Configuración fina de {@link RecordPicker}; se conserva para no
64
+ * romper consumidores.
65
+ */
62
66
  export function VehiclePicker({
63
67
  value,
64
68
  onChange,
@@ -67,23 +71,19 @@ export function VehiclePicker({
67
71
  endpoint,
68
72
  mapRecord,
69
73
  canCreate,
74
+ canEdit,
70
75
  createDefaults,
71
76
  disabled,
72
77
  placeholder,
73
78
  }: VehiclePickerProps) {
74
79
  const { t } = useTranslation()
75
- const api = useApi()
76
80
  const can = useCan()
77
81
  const [text, setText] = useState('')
78
- const [creating, setCreating] = useState(false)
79
- const { results, loading, error } = useAsyncSearch(text, search, { minChars: 2 })
82
+ const [open, setOpen] = useState(false)
83
+ const searchFn = useLatestSearch(search)
80
84
  const mayCreate = canCreate ?? can(`${snake(model)}.create`)
81
- const base = endpoint ?? `/data/${model}/me`
85
+ const mayEdit = canEdit ?? can(`${snake(model)}.update`)
82
86
 
83
- const pick = (v: VehicleResult) => {
84
- onChange(v)
85
- setText('')
86
- }
87
87
  const toResult = useCallback(
88
88
  (rec: Record<string, unknown>): VehicleResult =>
89
89
  mapRecord
@@ -100,89 +100,66 @@ export function VehiclePicker({
100
100
  },
101
101
  [mapRecord],
102
102
  )
103
-
104
- if (value) {
105
- return (
106
- <div data-slot="vehicle-picker" className="flex items-start justify-between gap-2 rounded-md border p-2">
107
- <div className="flex items-start gap-2">
108
- <Car className="mt-0.5 size-4 text-muted-foreground" aria-hidden />
109
- <div>
110
- <p className="text-sm font-medium">{value.plate || value.vin || t('vehiclePicker.noPlate', { defaultValue: 'Sin placa' })}</p>
111
- <p className="text-xs text-muted-foreground">
112
- {[[value.make, value.model, value.year].filter((x) => x != null && x !== '').join(' '), value.vin && value.plate ? value.vin : '', value.customer_name]
113
- .filter(Boolean)
114
- .join(' · ')}
115
- </p>
116
- </div>
117
- </div>
118
- {!disabled && (
119
- <Button type="button" size="icon" variant="ghost" onClick={() => onChange(null)} aria-label={t('vehiclePicker.clear', { defaultValue: 'Quitar vehículo' })}>
120
- <X className="size-4" />
121
- </Button>
122
- )}
123
- </div>
124
- )
125
- }
103
+ const { openCreate, openEdit, dialog } = useRecordPickerDialog({
104
+ model,
105
+ endpoint,
106
+ prefillField: 'plate',
107
+ createDefaults,
108
+ onSaved: (rec, kind) => {
109
+ const next = toResult(rec)
110
+ onChange(kind === 'edit' && value ? { ...value, ...next } : next)
111
+ setText('')
112
+ },
113
+ })
126
114
 
127
115
  return (
128
- <div data-slot="vehicle-picker" className="space-y-2">
129
- <div className="flex items-center gap-1.5">
130
- <div className="relative flex-1">
131
- <Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" aria-hidden />
132
- <Input
133
- className="pl-8"
134
- disabled={disabled}
135
- value={text}
136
- placeholder={placeholder ?? t('vehiclePicker.placeholder', { defaultValue: 'Placa o VIN' })}
137
- onChange={(e) => setText(e.target.value)}
138
- />
139
- </div>
140
- {mayCreate && (
141
- <Button type="button" variant="outline" size="icon" disabled={disabled} onClick={() => setCreating(true)} aria-label={t('vehiclePicker.create', { defaultValue: 'Nuevo vehículo' })}>
142
- <Plus className="size-4" />
143
- </Button>
144
- )}
145
- </div>
146
- {loading && <p className="text-sm text-muted-foreground">{t('common.searching', { defaultValue: 'Buscando…' })}</p>}
147
- {error && !loading && (
148
- <p role="alert" className="text-sm text-destructive">
149
- {t('vehiclePicker.error', { defaultValue: 'No se pudo buscar vehículos. Inténtalo de nuevo.' })}
150
- </p>
151
- )}
152
- {!loading && !error && text.trim().length >= 2 && results.length === 0 && (
153
- <EmptyState
154
- title={t('vehiclePicker.empty', { defaultValue: 'No encontramos ese vehículo' })}
155
- action={mayCreate ? { label: t('vehiclePicker.createAction', { defaultValue: 'Crear vehículo' }), onClick: () => setCreating(true) } : undefined}
156
- />
157
- )}
158
- {results.length > 0 && (
159
- <ul role="listbox" className="max-h-64 divide-y overflow-auto rounded-md border">
160
- {results.map((v) => (
161
- <li key={v.id} role="option" aria-selected={false}>
162
- <button type="button" className="flex w-full flex-col px-2 py-1.5 text-left hover:bg-accent" onClick={() => pick(v)}>
163
- <span className="text-sm font-medium">{v.plate || v.vin || v.id}</span>
164
- <span className="text-xs text-muted-foreground">{[describeVehicle({ ...v, plate: undefined }), v.customer_name].filter(Boolean).join(' · ')}</span>
165
- </button>
166
- </li>
167
- ))}
168
- </ul>
169
- )}
170
-
171
- {creating && (
172
- <CreateRecordDialog
173
- modelKey={model}
174
- open={creating}
175
- onOpenChange={setCreating}
176
- endpoint={base}
177
- defaults={createDefaults ?? (text.trim() ? { plate: text.trim() } : undefined)}
178
- onCreate={async (data) => {
179
- const res = await api.post(base, data)
180
- const rec = (res.data?.data ?? res.data) as Record<string, unknown>
181
- pick(toResult(rec))
182
- return rec.id != null ? { id: String(rec.id) } : undefined
183
- }}
184
- />
185
- )}
186
- </div>
116
+ <>
117
+ <RecordPicker<VehicleResult>
118
+ trigger="input"
119
+ slot="vehicle-picker"
120
+ search={searchFn}
121
+ minChars={2}
122
+ query={text}
123
+ onQueryChange={setText}
124
+ open={open}
125
+ onOpenChange={setOpen}
126
+ getKey={(v) => v.id}
127
+ getLabel={(v) => v.plate || v.vin || v.id}
128
+ getDescription={(v) => [describeVehicle({ ...v, plate: undefined }), v.customer_name].filter(Boolean).join(' · ')}
129
+ value={value?.id ?? null}
130
+ selected={value}
131
+ onSelect={(v) => onChange(v)}
132
+ onClear={disabled ? undefined : () => onChange(null)}
133
+ clearLabel={t('vehiclePicker.clear', { defaultValue: 'Quitar vehículo' })}
134
+ renderValue={(v) =>
135
+ v ? (
136
+ <span className="flex min-w-0 items-start gap-2">
137
+ <Car className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
138
+ <span className="block min-w-0">
139
+ <span className="block truncate text-sm font-medium">{v.plate || v.vin || t('vehiclePicker.noPlate', { defaultValue: 'Sin placa' })}</span>
140
+ <span className="block truncate text-xs text-muted-foreground">
141
+ {[[v.make, v.model, v.year].filter((x) => x != null && x !== '').join(' '), v.vin && v.plate ? v.vin : '', v.customer_name]
142
+ .filter(Boolean)
143
+ .join(' · ')}
144
+ </span>
145
+ </span>
146
+ </span>
147
+ ) : null
148
+ }
149
+ disabled={disabled}
150
+ placeholder={placeholder ?? t('vehiclePicker.placeholder', { defaultValue: 'Placa o VIN' })}
151
+ loadingText={t('common.searching', { defaultValue: 'Buscando…' })}
152
+ errorText={t('vehiclePicker.error', { defaultValue: 'No se pudo buscar vehículos. Inténtalo de nuevo.' })}
153
+ emptyText={t('vehiclePicker.empty', { defaultValue: 'No encontramos ese vehículo' })}
154
+ entityLabel={t('vehiclePicker.entity', { defaultValue: 'vehículo' })}
155
+ createLabel={t('vehiclePicker.create', { defaultValue: 'Nuevo vehículo' })}
156
+ editLabel={t('vehiclePicker.edit', { defaultValue: 'Editar vehículo' })}
157
+ createFooter="empty"
158
+ createFooterLabel={() => t('vehiclePicker.createAction', { defaultValue: 'Crear vehículo' })}
159
+ onCreate={mayCreate ? (q) => openCreate(q) : undefined}
160
+ onEdit={mayEdit && value ? () => openEdit(value.id) : undefined}
161
+ />
162
+ {dialog}
163
+ </>
187
164
  )
188
165
  }