@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.
- package/CHANGELOG.md +12 -0
- 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 +36 -6
- package/dist/dynamic-select-field.d.ts +9 -23
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +105 -172
- 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 +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +4 -0
- 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/dist/use-option-ids.d.ts +34 -0
- package/dist/use-option-ids.d.ts.map +1 -0
- package/dist/use-option-ids.js +138 -0
- package/dist/use-options-resolver.d.ts +4 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +10 -1
- package/package.json +1 -1
- 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__/edit-field-polish.test.tsx +6 -3
- package/src/__tests__/option-ids.test.tsx +181 -0
- package/src/__tests__/record-picker.test.tsx +469 -0
- 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 +53 -15
- package/src/dynamic-select-field.tsx +188 -379
- package/src/entity-select.tsx +53 -202
- package/src/index.ts +35 -2
- package/src/query-batch.ts +3 -0
- 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/src/use-option-ids.ts +168 -0
- package/src/use-options-resolver.ts +11 -1
- 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,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).
|
|
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 {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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).
|
|
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 [
|
|
78
|
-
const
|
|
83
|
+
const [open, setOpen] = useState(false)
|
|
84
|
+
const searchFn = useLatestSearch(search)
|
|
79
85
|
const mayCreate = canCreate ?? can(`${snake(model)}.create`)
|
|
80
|
-
const
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
133
|
-
</
|
|
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
|
-
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
{
|
|
151
|
-
<
|
|
152
|
-
<
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
}
|
package/src/business/index.ts
CHANGED
|
@@ -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).
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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,
|
|
11
|
+
import { forwardRef, useState, type KeyboardEvent } from 'react'
|
|
11
12
|
import { useTranslation } from 'react-i18next'
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 =
|
|
24
|
+
export type LineProductSearch = ProductSearchFn
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
34
|
-
* comandos (Enter agrega, Supr en
|
|
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
|
-
|
|
54
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
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
|
+
}
|