@asteby/metacore-runtime-react 49.4.0 → 49.6.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.
- package/CHANGELOG.md +14 -0
- package/dist/api-context.d.ts +9 -1
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +6 -4
- package/dist/business/customer-picker.d.ts +8 -2
- package/dist/business/customer-picker.d.ts.map +1 -1
- package/dist/business/customer-picker.js +30 -27
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-product-cell.d.ts +8 -10
- package/dist/business/line-product-cell.d.ts.map +1 -1
- package/dist/business/line-product-cell.js +13 -90
- package/dist/business/product-options.d.ts +41 -0
- package/dist/business/product-options.d.ts.map +1 -0
- package/dist/business/product-options.js +58 -0
- package/dist/business/product-picker.d.ts +5 -1
- package/dist/business/product-picker.d.ts.map +1 -1
- package/dist/business/product-picker.js +26 -35
- package/dist/business/relate-documents.d.ts +1 -1
- package/dist/business/relate-documents.d.ts.map +1 -1
- package/dist/business/relate-documents.js +6 -8
- package/dist/business/vehicle-picker.d.ts +8 -2
- package/dist/business/vehicle-picker.d.ts.map +1 -1
- package/dist/business/vehicle-picker.js +28 -28
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +28 -43
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +17 -5
- package/dist/dynamic-select-field.d.ts +7 -23
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +67 -161
- package/dist/dynamic-table.d.ts +31 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +37 -4
- package/dist/entity-select.d.ts +5 -2
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +23 -116
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +17 -1
- package/dist/record-picker-actions.d.ts +40 -2
- package/dist/record-picker-actions.d.ts.map +1 -1
- package/dist/record-picker-actions.js +44 -4
- package/dist/record-picker-dialog.d.ts +23 -0
- package/dist/record-picker-dialog.d.ts.map +1 -0
- package/dist/record-picker-dialog.js +37 -0
- package/dist/record-picker-option.d.ts +25 -0
- package/dist/record-picker-option.d.ts.map +1 -0
- package/dist/record-picker-option.js +55 -0
- package/dist/record-picker.d.ts +149 -0
- package/dist/record-picker.d.ts.map +1 -0
- package/dist/record-picker.js +372 -0
- package/package.json +1 -1
- package/src/__tests__/api-batch-opt-out.test.tsx +83 -0
- package/src/__tests__/business-pickers.test.tsx +6 -3
- package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
- package/src/__tests__/document-form-dialog.test.tsx +4 -3
- package/src/__tests__/document-lines-grid.test.tsx +2 -1
- package/src/__tests__/dynamic-table-bulk-actions.test.tsx +140 -0
- package/src/__tests__/dynamic-table-load-error.test.tsx +97 -0
- package/src/__tests__/record-picker.test.tsx +469 -0
- package/src/api-context.tsx +19 -4
- package/src/business/customer-picker.tsx +87 -98
- package/src/business/index.ts +1 -0
- package/src/business/line-product-cell.tsx +55 -180
- package/src/business/product-options.tsx +119 -0
- package/src/business/product-picker.tsx +63 -126
- package/src/business/relate-documents.tsx +36 -45
- package/src/business/vehicle-picker.tsx +76 -99
- package/src/dialogs/dynamic-record.tsx +44 -97
- package/src/dynamic-multi-select-field.tsx +39 -13
- package/src/dynamic-select-field.tsx +143 -368
- package/src/dynamic-table.tsx +91 -4
- package/src/entity-select.tsx +53 -202
- package/src/index.ts +27 -2
- package/src/query-batch.ts +18 -1
- package/src/record-picker-actions.tsx +101 -8
- package/src/record-picker-dialog.tsx +80 -0
- package/src/record-picker-option.tsx +96 -0
- package/src/record-picker.tsx +971 -0
- package/dist/business/use-async-search.d.ts +0 -10
- package/dist/business/use-async-search.d.ts.map +0 -1
- package/dist/business/use-async-search.js +0 -38
- 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
|
-
|
|
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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
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.
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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,
|
|
9
|
-
import { Badge, Button
|
|
10
|
-
import {
|
|
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.
|
|
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
|
|
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
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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)
|
|
3
|
-
//
|
|
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
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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.
|
|
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 [
|
|
79
|
-
const
|
|
82
|
+
const [open, setOpen] = useState(false)
|
|
83
|
+
const searchFn = useLatestSearch(search)
|
|
80
84
|
const mayCreate = canCreate ?? can(`${snake(model)}.create`)
|
|
81
|
-
const
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
{
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
)}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
}
|