@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 49.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 046d56f: `RecordPicker`: un solo selector de registro para todo el runtime. Búsqueda asíncrona (`search` con debounce/cancelación o `items` del resolver de opciones), estados cargando/vacío/error, selección simple y múltiple, crear (el «+» unido al trigger y «Crear …» al pie prellenado con el texto buscado) y editar el seleccionado (lápiz), opciones enriquecidas por props (avatar/iniciales, subtítulo, precio/stock), variantes `field`/`cell` y trigger `button`/`input` (texto libre), lista siempre en portal collision-aware por encima de los diálogos y teclado completo (↑/↓/Home/End/Enter/Esc/Tab) con ARIA combobox/listbox. `DynamicSelectField`, `EntitySelect`, `CustomerPicker`, `ProductPicker`, `VehiclePicker`, `LineProductCell`, `RelateDocuments`, `DynamicMultiSelectField` y el campo `search` legado del modal genérico pasan a ser configuraciones finas del primitivo (mismos props y exports; los wrappers quedan deprecados en JSDoc). Nuevos exports: `RecordPicker`, `useRecordSearch`, `useLatestSearch`, `useRecordPickerDialog`, `requestRecordCreate`/`requestRecordEdit`, `recordLabel`, `withSearchPrefill`, `OptionLead`/`OptionThumb`, `useProductSearch`/`ProductHitRow`. Las opciones son `role="option"` clicables sin `<button>` anidado (los tests que buscaban ese botón deben hacer click en la opción). Customer/Vehicle pickers suman el lápiz de edición del elegido (gateado por `<model>.update`).
|
|
8
|
+
|
|
9
|
+
## 49.5.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 2fca21f: `ApiProvider` acepta `batch={false}` para no agrupar lecturas en `POST /q`: un host cuyo backend no implementa `/q` ya no paga un POST fallido por cada lectura. Además, con el batch activo, si `/q` responde 404/405 el cliente deja de intentarlo tras el primer fallo y la lectura se resuelve por el GET normal. El comportamiento por defecto no cambia para hosts que sí tienen `/q`.
|
|
14
|
+
- 2575411: DynamicTable: nueva primitiva de acciones masivas. `extraBulkActions` (nodo o función `({ selectedRows, selectedIds, clearSelection, refresh }) => nodo`) pinta las acciones del host en la barra flotante de selección, junto al "Eliminar" integrado; `hideBulkDelete` oculta ese "Eliminar". La selección ahora se indexa por `id` del registro (antes por posición en la página) y, con paginación clásica, se vacía al cambiar de página, tamaño, orden, búsqueda o filtros de columna, de modo que una acción masiva nunca actúa sobre filas que el usuario ya no ve.
|
|
15
|
+
- e030a86: DynamicTable: un fallo del endpoint de lista que no es 403 (500, red o `success: false`) ya no se ve como "No se encontraron resultados": muestra "No se pudieron cargar los datos" con un botón "Reintentar" que vuelve a pedir la lista (claves `dynamic.load_error_title`, `dynamic.load_error_hint`, `dynamic.retry`). Nueva prop opcional `emptyState` para mostrar contenido de primer uso cuando la lista carga bien, está vacía y no hay búsqueda ni filtros activos; nunca tapa un error ni un 403.
|
|
16
|
+
|
|
3
17
|
## 49.4.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/dist/api-context.d.ts
CHANGED
|
@@ -20,9 +20,17 @@ export interface ApiClient {
|
|
|
20
20
|
}
|
|
21
21
|
export interface ApiProviderProps {
|
|
22
22
|
client: ApiClient;
|
|
23
|
+
/**
|
|
24
|
+
* Groups list, metadata and options reads into one `POST /q`. Defaults to
|
|
25
|
+
* `true`. Pass `false` when the host backend does not implement `/q`: every
|
|
26
|
+
* read then goes straight to its own GET and no POST is attempted. When
|
|
27
|
+
* left on and `/q` answers 404/405, the batch turns itself off for that
|
|
28
|
+
* client after the first failed attempt and the read falls back to GET.
|
|
29
|
+
*/
|
|
30
|
+
batch?: boolean;
|
|
23
31
|
children: React.ReactNode;
|
|
24
32
|
}
|
|
25
|
-
export declare function ApiProvider({ client, children }: ApiProviderProps): React.JSX.Element;
|
|
33
|
+
export declare function ApiProvider({ client, batch, children }: ApiProviderProps): React.JSX.Element;
|
|
26
34
|
/** Returns the host-injected api client. Throws if no <ApiProvider> is mounted. */
|
|
27
35
|
export declare function useApi(): ApiClient;
|
|
28
36
|
/** Optional branch context — hosts that support tenant branches can supply
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-context.d.ts","sourceRoot":"","sources":["../src/api-context.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAwD,MAAM,OAAO,CAAA;AAI5E,uEAAuE;AACvE,MAAM,WAAW,SAAS;IACtB,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;IACzE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;IACtF,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;IACrF,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;CAC/E;
|
|
1
|
+
{"version":3,"file":"api-context.d.ts","sourceRoot":"","sources":["../src/api-context.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAwD,MAAM,OAAO,CAAA;AAI5E,uEAAuE;AACvE,MAAM,WAAW,SAAS;IACtB,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;IACzE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;IACtF,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;IACrF,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;CAC/E;AAKD,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,SAAS,CAAA;IACjB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC5B;AAED,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,KAAY,EAAE,QAAQ,EAAE,EAAE,gBAAgB,qBA2B/E;AAmCD,mFAAmF;AACnF,wBAAgB,MAAM,IAAI,SAAS,CAoBlC;AAED;;wEAEwE;AACxE,MAAM,WAAW,WAAW;IACxB,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;CACzC;AAID,MAAM,WAAW,mBAAmB;IAChC,MAAM,EAAE,WAAW,CAAA;IACnB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC5B;AAED,wBAAgB,cAAc,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,mBAAmB,qBAEvE;AAED,wBAAgB,gBAAgB,IAAI,WAAW,CAE9C"}
|
package/dist/api-context.js
CHANGED
|
@@ -7,7 +7,8 @@ import { createContext, useContext, useEffect, useMemo } from 'react';
|
|
|
7
7
|
import { batchGet, invalidateQueryBatchData } from './query-batch';
|
|
8
8
|
import { invalidateOptionsCache } from './use-options-resolver';
|
|
9
9
|
const ApiContext = createContext(null);
|
|
10
|
-
|
|
10
|
+
const BatchContext = createContext(true);
|
|
11
|
+
export function ApiProvider({ client, batch = true, children }) {
|
|
11
12
|
// Hosts also write through their own client (native dialogs, settings
|
|
12
13
|
// screens) without going through useApi(). When the client is axios,
|
|
13
14
|
// every non-GET response drops what the runtime remembered as well.
|
|
@@ -28,7 +29,7 @@ export function ApiProvider({ client, children }) {
|
|
|
28
29
|
});
|
|
29
30
|
return () => interceptors.eject?.(id);
|
|
30
31
|
}, [client]);
|
|
31
|
-
return _jsx(ApiContext.Provider, { value: client, children: children });
|
|
32
|
+
return (_jsx(ApiContext.Provider, { value: client, children: _jsx(BatchContext.Provider, { value: batch, children: children }) }));
|
|
32
33
|
}
|
|
33
34
|
// POSTs that do not change any record a list or picker shows: the read batch
|
|
34
35
|
// (/q) and the bell inbox the host writes on every control toast
|
|
@@ -54,6 +55,7 @@ function mutating(request) {
|
|
|
54
55
|
/** Returns the host-injected api client. Throws if no <ApiProvider> is mounted. */
|
|
55
56
|
export function useApi() {
|
|
56
57
|
const ctx = useContext(ApiContext);
|
|
58
|
+
const batch = useContext(BatchContext);
|
|
57
59
|
if (!ctx) {
|
|
58
60
|
throw new Error('useApi() requires an <ApiProvider> ancestor. Hosts must inject an axios-like client via runtime-react ApiProvider.');
|
|
59
61
|
}
|
|
@@ -64,11 +66,11 @@ export function useApi() {
|
|
|
64
66
|
return useMemo(() => ({
|
|
65
67
|
// Each call reaches the host client with the arguments it was given —
|
|
66
68
|
// never a trailing `undefined` it didn't ask for.
|
|
67
|
-
get: (...args) => batchGet(ctx, ...args),
|
|
69
|
+
get: (...args) => batch ? batchGet(ctx, ...args) : ctx.get(...args),
|
|
68
70
|
post: (...args) => isWrite('post', args[0]) ? mutating(ctx.post(...args)) : ctx.post(...args),
|
|
69
71
|
put: (...args) => mutating(ctx.put(...args)),
|
|
70
72
|
delete: (...args) => mutating(ctx.delete(...args)),
|
|
71
|
-
}), [ctx]);
|
|
73
|
+
}), [ctx, batch]);
|
|
72
74
|
}
|
|
73
75
|
const BranchContext = createContext({ id: undefined });
|
|
74
76
|
export function BranchProvider({ branch, children }) {
|
|
@@ -17,7 +17,7 @@ export interface CustomerPickerProps {
|
|
|
17
17
|
value: CustomerResult | null;
|
|
18
18
|
/** Evento único de cambio: cliente elegido/creado, o `null` al limpiar. */
|
|
19
19
|
onChange: (customer: CustomerResult | null) => void;
|
|
20
|
-
/** Búsqueda (nombre, ID fiscal, teléfono, placa, VIN).
|
|
20
|
+
/** Búsqueda (nombre, ID fiscal, teléfono, placa, VIN). */
|
|
21
21
|
search: (q: string, signal: AbortSignal) => Promise<CustomerResult[]>;
|
|
22
22
|
/** Modelo del kernel para el alta rápida. Default `Customer`. */
|
|
23
23
|
model?: string;
|
|
@@ -29,11 +29,17 @@ export interface CustomerPickerProps {
|
|
|
29
29
|
validateTaxId?: (taxId: string) => string | undefined;
|
|
30
30
|
/** Overrides de permiso (default `<model>.create` con useCan). */
|
|
31
31
|
canCreate?: boolean;
|
|
32
|
+
/** Override de permiso para editar al elegido (default `<model>.update`). */
|
|
33
|
+
canEdit?: boolean;
|
|
32
34
|
/** Valores iniciales del alta rápida (p. ej. el texto buscado como nombre). */
|
|
33
35
|
createDefaults?: Record<string, unknown>;
|
|
34
36
|
currency?: string;
|
|
35
37
|
disabled?: boolean;
|
|
36
38
|
placeholder?: string;
|
|
37
39
|
}
|
|
38
|
-
|
|
40
|
+
/**
|
|
41
|
+
* @deprecated Configuración fina de {@link RecordPicker}; se conserva para no
|
|
42
|
+
* romper consumidores.
|
|
43
|
+
*/
|
|
44
|
+
export declare function CustomerPicker({ value, onChange, search, model, endpoint, mapRecord, validateTaxId, canCreate, canEdit, createDefaults, currency, disabled, placeholder, }: CustomerPickerProps): import("react").JSX.Element;
|
|
39
45
|
//# sourceMappingURL=customer-picker.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"customer-picker.d.ts","sourceRoot":"","sources":["../../src/business/customer-picker.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"customer-picker.d.ts","sourceRoot":"","sources":["../../src/business/customer-picker.tsx"],"names":[],"mappings":"AAYA,MAAM,WAAW,cAAc;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,sFAAsF;IACtF,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;CACpB;AAED,MAAM,WAAW,mBAAmB;IAChC,KAAK,EAAE,cAAc,GAAG,IAAI,CAAA;IAC5B,2EAA2E;IAC3E,QAAQ,EAAE,CAAC,QAAQ,EAAE,cAAc,GAAG,IAAI,KAAK,IAAI,CAAA;IACnD,0DAA0D;IAC1D,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,cAAc,EAAE,CAAC,CAAA;IACrE,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,6EAA6E;IAC7E,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,cAAc,CAAA;IAC5D,sFAAsF;IACtF,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAA;IACrD,kEAAkE;IAClE,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,6EAA6E;IAC7E,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,+EAA+E;IAC/E,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;CACvB;AAQD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAC3B,KAAK,EACL,QAAQ,EACR,MAAM,EACN,KAAkB,EAClB,QAAQ,EACR,SAAS,EACT,aAAa,EACb,SAAS,EACT,OAAO,EACP,cAAc,EACd,QAAQ,EACR,QAAQ,EACR,WAAW,GACd,EAAE,mBAAmB,+BAgHrB"}
|
|
@@ -1,35 +1,33 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
// CustomerPicker + alta rápida — busca por nombre, RFC/ID fiscal, teléfono, placa
|
|
3
|
-
// o VIN; muestra saldo, crédito y alertas (benchmark §7).
|
|
3
|
+
// o VIN; muestra saldo, crédito y alertas (benchmark §7). Configuración del
|
|
4
|
+
// <RecordPicker> compartido: el alta rápida (y la edición del elegido) reutiliza
|
|
4
5
|
// el <CreateRecordDialog> dinámico del modelo (formulario desde metadata).
|
|
5
6
|
import { useCallback, useState } from 'react';
|
|
6
7
|
import { useTranslation } from 'react-i18next';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { useApi } from '../api-context';
|
|
8
|
+
import { Badge } from '@asteby/metacore-ui';
|
|
9
|
+
import { RecordPicker, useLatestSearch } from '../record-picker';
|
|
10
|
+
import { useRecordPickerDialog } from '../record-picker-dialog';
|
|
11
11
|
import { useCan } from '../permissions-context';
|
|
12
12
|
import { useFormatter } from './format';
|
|
13
|
-
import { useAsyncSearch } from './use-async-search';
|
|
14
|
-
import { EmptyState } from './feedback';
|
|
15
13
|
function snake(model) {
|
|
16
14
|
return model.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
17
15
|
}
|
|
18
|
-
|
|
16
|
+
const customerLine = (c) => [c.tax_id, c.phone, c.plate, c.vin].filter(Boolean).join(' · ');
|
|
17
|
+
/**
|
|
18
|
+
* @deprecated Configuración fina de {@link RecordPicker}; se conserva para no
|
|
19
|
+
* romper consumidores.
|
|
20
|
+
*/
|
|
21
|
+
export function CustomerPicker({ value, onChange, search, model = 'Customer', endpoint, mapRecord, validateTaxId, canCreate, canEdit, createDefaults, currency, disabled, placeholder, }) {
|
|
19
22
|
const { t } = useTranslation();
|
|
20
|
-
const api = useApi();
|
|
21
23
|
const can = useCan();
|
|
22
24
|
const fmt = useFormatter({ currency });
|
|
23
25
|
const [text, setText] = useState('');
|
|
24
|
-
const [
|
|
25
|
-
const
|
|
26
|
+
const [open, setOpen] = useState(false);
|
|
27
|
+
const searchFn = useLatestSearch(search);
|
|
26
28
|
const mayCreate = canCreate ?? can(`${snake(model)}.create`);
|
|
27
|
-
const
|
|
29
|
+
const mayEdit = canEdit ?? can(`${snake(model)}.update`);
|
|
28
30
|
const taxIdError = value?.tax_id && validateTaxId ? validateTaxId(value.tax_id) : undefined;
|
|
29
|
-
const pick = (c) => {
|
|
30
|
-
onChange(c);
|
|
31
|
-
setText('');
|
|
32
|
-
};
|
|
33
31
|
const toResult = useCallback((rec) => mapRecord
|
|
34
32
|
? mapRecord(rec)
|
|
35
33
|
: {
|
|
@@ -38,14 +36,19 @@ export function CustomerPicker({ value, onChange, search, model = 'Customer', en
|
|
|
38
36
|
tax_id: typeof rec.tax_id === 'string' ? rec.tax_id : undefined,
|
|
39
37
|
phone: typeof rec.phone === 'string' ? rec.phone : undefined,
|
|
40
38
|
}, [mapRecord]);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
const { openCreate, openEdit, dialog } = useRecordPickerDialog({
|
|
40
|
+
model,
|
|
41
|
+
endpoint,
|
|
42
|
+
prefillField: 'name',
|
|
43
|
+
createDefaults,
|
|
44
|
+
onSaved: (rec, kind) => {
|
|
45
|
+
const next = toResult(rec);
|
|
46
|
+
// Al editar se conservan saldo/crédito/alertas que el registro no trae.
|
|
47
|
+
onChange(kind === 'edit' && value ? { ...value, ...next } : next);
|
|
48
|
+
setText('');
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const overLimit = !!value && value.credit_limit != null && (value.balance ?? 0) > value.credit_limit;
|
|
52
|
+
const below = value ? (_jsxs(_Fragment, { children: [(value.balance != null && value.balance !== 0) || value.credit_limit != null || value.alerts?.length ? (_jsxs("div", { className: "flex flex-wrap gap-1.5", children: [value.balance != null && value.balance !== 0 && (_jsxs(Badge, { variant: overLimit ? 'danger' : 'warning', children: [t('customerPicker.balance', { defaultValue: 'Adeudo' }), " ", fmt.money(value.balance)] })), value.credit_limit != null && (_jsxs(Badge, { variant: "muted", children: [t('customerPicker.credit', { defaultValue: 'Crédito' }), " ", fmt.money(value.credit_limit)] })), value.alerts?.map((a) => (_jsx(Badge, { variant: "danger", children: a }, a)))] })) : null, taxIdError && _jsx("p", { role: "alert", className: "text-xs text-destructive", children: taxIdError })] })) : null;
|
|
53
|
+
return (_jsxs(_Fragment, { children: [_jsx(RecordPicker, { trigger: "input", slot: "customer-picker", search: searchFn, minChars: 2, query: text, onQueryChange: setText, open: open, onOpenChange: setOpen, getKey: (c) => c.id, getLabel: (c) => c.name, renderItem: (c) => (_jsxs("span", { className: "flex w-full min-w-0 items-center justify-between gap-2", children: [_jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block truncate font-medium", children: c.name }), _jsx("span", { className: "block truncate text-xs text-muted-foreground", children: customerLine(c) })] }), c.balance != null && c.balance > 0 && _jsx(Badge, { variant: "warning", children: fmt.money(c.balance) })] })), value: value?.id ?? null, selected: value, onSelect: (c) => onChange(c), onClear: disabled ? undefined : () => onChange(null), clearLabel: t('customerPicker.clear', { defaultValue: 'Quitar cliente' }), renderValue: (c) => c ? (_jsxs("span", { className: "block min-w-0", children: [_jsx("span", { className: "block truncate text-sm font-medium", children: c.name }), customerLine(c) && _jsx("span", { className: "block truncate text-xs text-muted-foreground", children: customerLine(c) })] })) : null, below: below, disabled: disabled, placeholder: placeholder ?? t('customerPicker.placeholder', { defaultValue: 'Nombre, RFC, teléfono, placa o VIN' }), loadingText: t('common.searching', { defaultValue: 'Buscando…' }), errorText: t('customerPicker.error', { defaultValue: 'No se pudo buscar clientes. Inténtalo de nuevo.' }), emptyText: t('customerPicker.empty', { defaultValue: 'No encontramos a ese cliente' }), entityLabel: t('customerPicker.entity', { defaultValue: 'cliente' }), createLabel: t('customerPicker.create', { defaultValue: 'Nuevo cliente' }), editLabel: t('customerPicker.edit', { defaultValue: 'Editar cliente' }), createFooter: "empty", createFooterLabel: () => t('customerPicker.createAction', { defaultValue: 'Crear cliente' }), onCreate: mayCreate ? (q) => openCreate(q) : undefined, onEdit: mayEdit && value ? () => openEdit(value.id) : undefined }), dialog] }));
|
|
51
54
|
}
|
package/dist/business/index.d.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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/business/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAA;AACxB,cAAc,gBAAgB,CAAA;AAC9B,cAAc,YAAY,CAAA;AAC1B,cAAc,cAAc,CAAA;AAC5B,cAAc,kBAAkB,CAAA;AAChC,cAAc,qBAAqB,CAAA;AACnC,cAAc,qBAAqB,CAAA;AACnC,cAAc,WAAW,CAAA;AACzB,cAAc,mBAAmB,CAAA;AACjC,cAAc,UAAU,CAAA;AACxB,cAAc,sBAAsB,CAAA;AACpC,cAAc,kBAAkB,CAAA;AAChC,cAAc,kBAAkB,CAAA;AAChC,cAAc,mBAAmB,CAAA;AACjC,cAAc,WAAW,CAAA;AACzB,cAAc,UAAU,CAAA;AACxB,cAAc,iBAAiB,CAAA;AAC/B,cAAc,cAAc,CAAA;AAC5B,cAAc,wBAAwB,CAAA;AACtC,cAAc,kBAAkB,CAAA;AAChC,cAAc,oBAAoB,CAAA;AAClC,cAAc,qBAAqB,CAAA;AACnC,cAAc,0BAA0B,CAAA;AACxC,cAAc,yBAAyB,CAAA;AACvC,cAAc,0BAA0B,CAAA;AACxC,cAAc,qBAAqB,CAAA;AACnC,cAAc,mBAAmB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/business/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAA;AACxB,cAAc,gBAAgB,CAAA;AAC9B,cAAc,YAAY,CAAA;AAC1B,cAAc,cAAc,CAAA;AAC5B,cAAc,kBAAkB,CAAA;AAChC,cAAc,qBAAqB,CAAA;AACnC,cAAc,qBAAqB,CAAA;AACnC,cAAc,WAAW,CAAA;AACzB,cAAc,mBAAmB,CAAA;AACjC,cAAc,UAAU,CAAA;AACxB,cAAc,sBAAsB,CAAA;AACpC,cAAc,kBAAkB,CAAA;AAChC,cAAc,kBAAkB,CAAA;AAChC,cAAc,mBAAmB,CAAA;AACjC,cAAc,mBAAmB,CAAA;AACjC,cAAc,WAAW,CAAA;AACzB,cAAc,UAAU,CAAA;AACxB,cAAc,iBAAiB,CAAA;AAC/B,cAAc,cAAc,CAAA;AAC5B,cAAc,wBAAwB,CAAA;AACtC,cAAc,kBAAkB,CAAA;AAChC,cAAc,oBAAoB,CAAA;AAClC,cAAc,qBAAqB,CAAA;AACnC,cAAc,0BAA0B,CAAA;AACxC,cAAc,yBAAyB,CAAA;AACvC,cAAc,0BAA0B,CAAA;AACxC,cAAc,qBAAqB,CAAA;AACnC,cAAc,mBAAmB,CAAA"}
|
package/dist/business/index.js
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,12 +1,9 @@
|
|
|
1
1
|
import { type KeyboardEvent } from 'react';
|
|
2
|
-
import { type
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
product: ProductResult;
|
|
8
|
-
variant?: ProductVariant;
|
|
9
|
-
}
|
|
2
|
+
import { type ProductHit, type ProductSearchFn } from './product-options';
|
|
3
|
+
import type { ProductResult, ProductVariant } from './product-search';
|
|
4
|
+
export type LineProductSearch = ProductSearchFn;
|
|
5
|
+
/** @deprecated Alias de {@link ProductHit}. */
|
|
6
|
+
export type LineProductHit = ProductHit;
|
|
10
7
|
export interface LineProductCellProps {
|
|
11
8
|
search: LineProductSearch;
|
|
12
9
|
/** Texto de la celda (la descripción del renglón, o el borrador del renglón vacío). */
|
|
@@ -14,8 +11,9 @@ export interface LineProductCellProps {
|
|
|
14
11
|
onTextChange: (text: string) => void;
|
|
15
12
|
onPick: (product: ProductResult, variant?: ProductVariant) => void;
|
|
16
13
|
/**
|
|
17
|
-
* Teclas que el buscador no consume (lista cerrada
|
|
18
|
-
* comandos (Enter agrega, Supr en
|
|
14
|
+
* Teclas que el buscador no consume (lista cerrada, Enter sin
|
|
15
|
+
* coincidencias…): el grid aplica sus comandos (Enter agrega, Supr en
|
|
16
|
+
* celda vacía borra…).
|
|
19
17
|
*/
|
|
20
18
|
onKeyDownClosed?: (e: KeyboardEvent<HTMLInputElement>) => void;
|
|
21
19
|
warehouseId?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"line-product-cell.d.ts","sourceRoot":"","sources":["../../src/business/line-product-cell.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"line-product-cell.d.ts","sourceRoot":"","sources":["../../src/business/line-product-cell.tsx"],"names":[],"mappings":"AAUA,OAAO,EAAwB,KAAK,aAAa,EAAE,MAAM,OAAO,CAAA;AAGhE,OAAO,EAKH,KAAK,UAAU,EACf,KAAK,eAAe,EACvB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAErE,MAAM,MAAM,iBAAiB,GAAG,eAAe,CAAA;AAE/C,+CAA+C;AAC/C,MAAM,MAAM,cAAc,GAAG,UAAU,CAAA;AAEvC,MAAM,WAAW,oBAAoB;IACjC,MAAM,EAAE,iBAAiB,CAAA;IACzB,uFAAuF;IACvF,IAAI,EAAE,MAAM,CAAA;IACZ,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IACpC,MAAM,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,OAAO,CAAC,EAAE,cAAc,KAAK,IAAI,CAAA;IAClE;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,CAAC,EAAE,aAAa,CAAC,gBAAgB,CAAC,KAAK,IAAI,CAAA;IAC9D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,eAAO,MAAM,eAAe,mHA0C1B,CAAA"}
|
|
@@ -1,100 +1,23 @@
|
|
|
1
|
-
import { jsx as _jsx
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
// LineProductCell — buscador de producto DENTRO de la celda «Descripción» de un
|
|
3
|
-
// renglón (patrón de editor de facturas pro: Odoo/Holded/Linear).
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// renglón (patrón de editor de facturas pro: Odoo/Holded/Linear). Es una
|
|
4
|
+
// configuración del <RecordPicker> compartido (trigger «input», texto libre,
|
|
5
|
+
// variante celda): ocupa solo su celda y la lista va en un portal — nunca la
|
|
6
|
+
// recorta el overflow de la tabla ni del modal, queda por encima del diálogo y
|
|
7
|
+
// se abre hacia arriba si abajo no hay espacio.
|
|
7
8
|
//
|
|
8
9
|
// Teclado: ↑/↓ recorren, Enter elige (el grid pasa el foco a «Cant.»), Esc
|
|
9
10
|
// cierra, Tab cierra y avanza normal. El texto escrito sin elegir queda como
|
|
10
11
|
// descripción libre (el renglón libre sigue funcionando igual).
|
|
11
|
-
import { forwardRef,
|
|
12
|
+
import { forwardRef, useState } from 'react';
|
|
12
13
|
import { useTranslation } from 'react-i18next';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { useFormatter } from './format';
|
|
16
|
-
import { useAsyncSearch } from './use-async-search';
|
|
17
|
-
import { useDebouncedValue } from '../use-debounced-value';
|
|
18
|
-
import { availableStock, parseProductQuery } from './product-search';
|
|
14
|
+
import { RecordPicker } from '../record-picker';
|
|
15
|
+
import { ProductHitRow, productHitKey, productHitLabel, useProductSearch, } from './product-options';
|
|
19
16
|
export const LineProductCell = forwardRef(function LineProductCell({ search, text, onTextChange, onPick, onKeyDownClosed, warehouseId, currency, placeholder, ariaLabel, disabled, invalid, className, dataCell }, ref) {
|
|
20
17
|
const { t } = useTranslation();
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const anchorRef = useRef(null);
|
|
24
|
-
// Solo se abre mientras se escribe: enfocar un renglón libre ya capturado no
|
|
25
|
-
// despliega «sin resultados» encima de la tabla.
|
|
18
|
+
// Solo se busca/abre mientras se escribe: enfocar un renglón libre ya
|
|
19
|
+
// capturado no despliega «sin resultados» encima de la tabla.
|
|
26
20
|
const [typing, setTyping] = useState(false);
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const run = useMemo(() => (q, signal) => {
|
|
30
|
-
const p = parseProductQuery(q);
|
|
31
|
-
return p.kind === 'empty' ? Promise.resolve([]) : search(p, signal);
|
|
32
|
-
}, [search]);
|
|
33
|
-
const minChars = parsed.kind === 'barcode' ? 8 : 2;
|
|
34
|
-
const delay = parsed.kind === 'barcode' ? 0 : 250;
|
|
35
|
-
const { results, loading: fetching } = useAsyncSearch(typing ? text : '', run, { minChars, delay });
|
|
36
|
-
// Mientras el debounce no alcanza lo escrito, la lista dice «Buscando…» y no
|
|
37
|
-
// un «sin coincidencias» prematuro.
|
|
38
|
-
const settled = useDebouncedValue(text.trim(), delay) === text.trim();
|
|
39
|
-
const loading = fetching || !settled;
|
|
40
|
-
const flat = useMemo(() => results.flatMap((p) => (p.variants?.length ?? 0) > 0 ? p.variants.map((v) => ({ product: p, variant: v })) : [{ product: p }]), [results]);
|
|
41
|
-
const queryReady = typing && text.trim().length >= minChars;
|
|
42
|
-
const open = queryReady && (loading || flat.length > 0 || text.trim().length >= 2);
|
|
43
|
-
const hasHits = flat.length > 0;
|
|
44
|
-
const close = () => {
|
|
45
|
-
setTyping(false);
|
|
46
|
-
setActive(0);
|
|
47
|
-
};
|
|
48
|
-
const choose = (idx) => {
|
|
49
|
-
const hit = flat[idx];
|
|
50
|
-
if (!hit)
|
|
51
|
-
return;
|
|
52
|
-
close();
|
|
53
|
-
onPick(hit.product, hit.variant);
|
|
54
|
-
};
|
|
55
|
-
const onKeyDown = (e) => {
|
|
56
|
-
if (open && hasHits) {
|
|
57
|
-
if (e.key === 'ArrowDown') {
|
|
58
|
-
e.preventDefault();
|
|
59
|
-
setActive((n) => Math.min(flat.length - 1, n + 1));
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
if (e.key === 'ArrowUp') {
|
|
63
|
-
e.preventDefault();
|
|
64
|
-
setActive((n) => Math.max(0, n - 1));
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
if (e.key === 'Enter') {
|
|
68
|
-
e.preventDefault();
|
|
69
|
-
choose(active);
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
if (open && e.key === 'Escape') {
|
|
74
|
-
// Cierra la lista sin cerrar el diálogo que la contiene.
|
|
75
|
-
e.preventDefault();
|
|
76
|
-
e.stopPropagation();
|
|
77
|
-
close();
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
if (e.key === 'Tab')
|
|
81
|
-
close();
|
|
82
|
-
onKeyDownClosed?.(e);
|
|
83
|
-
};
|
|
84
|
-
return (_jsxs(Popover, { open: open, onOpenChange: (o) => !o && close(), children: [_jsx(PopoverAnchor, { asChild: true, children: _jsxs("div", { ref: anchorRef, className: "relative w-full min-w-0", "data-slot": "line-product-cell", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground", "aria-hidden": true }), _jsx(Input, { ref: ref, value: text, disabled: disabled, role: "combobox", "aria-autocomplete": "list", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open && hasHits ? `${listId}-${active}` : undefined, "aria-label": ariaLabel, "aria-invalid": invalid || undefined, autoComplete: "off", "data-cell": dataCell, placeholder: placeholder ?? t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' }), className: 'h-8 pl-8 ' + (className ?? ''), onChange: (e) => {
|
|
85
|
-
onTextChange(e.target.value);
|
|
86
|
-
setTyping(true);
|
|
87
|
-
setActive(0);
|
|
88
|
-
}, onBlur: () => close(), onKeyDown: onKeyDown })] }) }), _jsx(PopoverContent, { align: "start", sideOffset: 4, collisionPadding: 8, className: "p-1", "data-slot": "line-product-results",
|
|
89
|
-
// El foco se queda en la celda: se sigue escribiendo mientras se ve la lista.
|
|
90
|
-
onOpenAutoFocus: (e) => e.preventDefault(), onCloseAutoFocus: (e) => e.preventDefault(), onInteractOutside: (e) => {
|
|
91
|
-
if (anchorRef.current?.contains(e.target))
|
|
92
|
-
e.preventDefault();
|
|
93
|
-
}, style: { width: 'max(var(--radix-popover-trigger-width), 22rem)', maxWidth: 'calc(100vw - 1rem)' }, children: hasHits ? (_jsx("ul", { id: listId, role: "listbox", "aria-label": ariaLabel, className: "max-h-72 overflow-auto", children: flat.map((hit, idx) => {
|
|
94
|
-
const price = hit.variant?.price ?? hit.product.price;
|
|
95
|
-
const stock = availableStock(hit.variant ?? hit.product, warehouseId);
|
|
96
|
-
const meta = [hit.variant?.sku ?? hit.product.sku, hit.product.tire?.normalized].filter(Boolean).join(' · ');
|
|
97
|
-
return (_jsx("li", { id: `${listId}-${idx}`, role: "option", "aria-selected": idx === active, children: _jsxs("button", { type: "button", tabIndex: -1, className: '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 ' +
|
|
98
|
-
(idx === active ? 'bg-accent text-accent-foreground' : ''), onMouseDown: (e) => e.preventDefault(), onMouseEnter: () => setActive(idx), onClick: () => choose(idx), children: [_jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block truncate font-medium", children: hit.variant ? `${hit.product.name} · ${hit.variant.label}` : hit.product.name }), meta && _jsx("span", { className: "block truncate text-xs text-muted-foreground", children: meta })] }), _jsxs("span", { className: "flex shrink-0 items-center gap-2", children: [stock != null && (_jsx(Badge, { variant: "secondary", className: "font-normal tabular-nums", children: stock })), price != null && _jsx("span", { className: "tabular-nums", children: fmt.money(price) })] })] }) }, `${hit.product.id}:${hit.variant?.id ?? ''}`));
|
|
99
|
-
}) })) : loading ? (_jsxs("p", { className: "flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground", children: [_jsx(Loader2, { className: "size-4 animate-spin", "aria-hidden": true }), t('common.searching', { defaultValue: 'Buscando…' })] })) : (_jsx("p", { className: "px-2 py-3 text-sm text-muted-foreground", children: t('lineItems.noProduct', { defaultValue: 'Sin coincidencias. Se usará como descripción libre.' }) })) })] }));
|
|
21
|
+
const { hits, loading, minChars } = useProductSearch(typing ? text : '', search, typing);
|
|
22
|
+
return (_jsx(RecordPicker, { trigger: "input", variant: "cell", freeText: true, anchorSlot: "line-product-cell", contentSlot: "line-product-results", minListWidth: "22rem", items: hits, loading: loading, minChars: minChars, query: text, onQueryChange: onTextChange, open: typing, onOpenChange: setTyping, getKey: productHitKey, getLabel: productHitLabel, renderItem: (hit) => _jsx(ProductHitRow, { hit: hit, warehouseId: warehouseId, currency: currency, compact: true }), onSelect: (hit) => onPick(hit.product, hit.variant), onUnhandledKeyDown: onKeyDownClosed, inputRef: ref, ariaLabel: ariaLabel, listLabel: ariaLabel, disabled: disabled, invalid: invalid, triggerClassName: className, triggerProps: { 'data-cell': dataCell }, placeholder: placeholder ?? t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' }), loadingText: t('common.searching', { defaultValue: 'Buscando…' }), emptyText: t('lineItems.noProduct', { defaultValue: 'Sin coincidencias. Se usará como descripción libre.' }) }));
|
|
100
23
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ProductQuery, type ProductResult, type ProductVariant } from './product-search';
|
|
2
|
+
/** A pickable row: a product, or one concrete variant of it. */
|
|
3
|
+
export interface ProductHit {
|
|
4
|
+
product: ProductResult;
|
|
5
|
+
variant?: ProductVariant;
|
|
6
|
+
}
|
|
7
|
+
export type ProductSearchFn = (query: Exclude<ProductQuery, {
|
|
8
|
+
kind: 'empty';
|
|
9
|
+
}>, signal: AbortSignal) => Promise<ProductResult[]>;
|
|
10
|
+
/** Products with variants expand to one row per variant. */
|
|
11
|
+
export declare function flattenProductHits(results: readonly ProductResult[]): ProductHit[];
|
|
12
|
+
export declare const productHitKey: (h: ProductHit) => string;
|
|
13
|
+
export declare const productHitLabel: (h: ProductHit) => string;
|
|
14
|
+
export declare const productHitMeta: (h: ProductHit) => string;
|
|
15
|
+
/**
|
|
16
|
+
* Classified product search: a barcode is exact (8+ digits, no debounce), the
|
|
17
|
+
* rest waits for 2 characters and 250ms. `search` needn't be memoized.
|
|
18
|
+
*/
|
|
19
|
+
export declare function useProductSearch(text: string, search: ProductSearchFn, enabled?: boolean): {
|
|
20
|
+
parsed: ProductQuery;
|
|
21
|
+
results: ProductResult[];
|
|
22
|
+
hits: ProductHit[];
|
|
23
|
+
loading: boolean;
|
|
24
|
+
error: boolean;
|
|
25
|
+
minChars: number;
|
|
26
|
+
};
|
|
27
|
+
/** Stock + price at the right of a product row. `compact` → bare stock count (table cell). */
|
|
28
|
+
export declare function ProductHitTrailing({ hit, warehouseId, currency, compact, }: {
|
|
29
|
+
hit: ProductHit;
|
|
30
|
+
warehouseId?: string;
|
|
31
|
+
currency?: string;
|
|
32
|
+
compact?: boolean;
|
|
33
|
+
}): import("react").JSX.Element;
|
|
34
|
+
/** Option body: bold name (· variant), muted meta line, stock/price right. */
|
|
35
|
+
export declare function ProductHitRow({ hit, warehouseId, currency, compact }: {
|
|
36
|
+
hit: ProductHit;
|
|
37
|
+
warehouseId?: string;
|
|
38
|
+
currency?: string;
|
|
39
|
+
compact?: boolean;
|
|
40
|
+
}): import("react").JSX.Element;
|
|
41
|
+
//# sourceMappingURL=product-options.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"product-options.d.ts","sourceRoot":"","sources":["../../src/business/product-options.tsx"],"names":[],"mappings":"AAUA,OAAO,EAGH,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,cAAc,EACtB,MAAM,kBAAkB,CAAA;AAEzB,gEAAgE;AAChE,MAAM,WAAW,UAAU;IACvB,OAAO,EAAE,aAAa,CAAA;IACtB,OAAO,CAAC,EAAE,cAAc,CAAA;CAC3B;AAED,MAAM,MAAM,eAAe,GAAG,CAC1B,KAAK,EAAE,OAAO,CAAC,YAAY,EAAE;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC,EAC/C,MAAM,EAAE,WAAW,KAClB,OAAO,CAAC,aAAa,EAAE,CAAC,CAAA;AAE7B,4DAA4D;AAC5D,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,SAAS,aAAa,EAAE,GAAG,UAAU,EAAE,CAIlF;AAED,eAAO,MAAM,aAAa,GAAI,GAAG,UAAU,KAAG,MAAkD,CAAA;AAChG,eAAO,MAAM,eAAe,GAAI,GAAG,UAAU,KAAG,MACyB,CAAA;AACzE,eAAO,MAAM,cAAc,GAAI,GAAG,UAAU,KAAG,MAO3B,CAAA;AAEpB;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,EAAE,OAAO,UAAO;;;;;;;EAerF;AAED,8FAA8F;AAC9F,wBAAgB,kBAAkB,CAAC,EAC/B,GAAG,EACH,WAAW,EACX,QAAQ,EACR,OAAe,GAClB,EAAE;IACC,GAAG,EAAE,UAAU,CAAA;IACf,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,OAAO,CAAA;CACpB,+BAsBA;AAED,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,EAAE,GAAG,EAAE,WAAW,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE;IAAE,GAAG,EAAE,UAAU,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,+BAarJ"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Product options for <RecordPicker>: one search (products, tires, variants)
|
|
3
|
+
// and one option row (name · variant, SKU / tire size / supplier code, stock and
|
|
4
|
+
// price on the right) shared by ProductPicker and the DocumentEditor's
|
|
5
|
+
// LineProductCell — so both product pickers are configurations of the same
|
|
6
|
+
// primitive instead of two hand-rolled lists.
|
|
7
|
+
import { useMemo } from 'react';
|
|
8
|
+
import { useTranslation } from 'react-i18next';
|
|
9
|
+
import { Badge } from '@asteby/metacore-ui/primitives';
|
|
10
|
+
import { useLatestSearch, useRecordSearch } from '../record-picker';
|
|
11
|
+
import { useFormatter } from './format';
|
|
12
|
+
import { availableStock, parseProductQuery, } from './product-search';
|
|
13
|
+
/** Products with variants expand to one row per variant. */
|
|
14
|
+
export function flattenProductHits(results) {
|
|
15
|
+
return results.flatMap((p) => (p.variants?.length ?? 0) > 0 ? p.variants.map((v) => ({ product: p, variant: v })) : [{ product: p }]);
|
|
16
|
+
}
|
|
17
|
+
export const productHitKey = (h) => `${h.product.id}:${h.variant?.id ?? ''}`;
|
|
18
|
+
export const productHitLabel = (h) => h.variant ? `${h.product.name} · ${h.variant.label}` : h.product.name;
|
|
19
|
+
export const productHitMeta = (h) => [
|
|
20
|
+
h.variant?.sku ?? h.product.sku,
|
|
21
|
+
h.product.tire?.normalized,
|
|
22
|
+
h.product.supplier_sku && `Prov. ${h.product.supplier_sku}`,
|
|
23
|
+
]
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.join(' · ');
|
|
26
|
+
/**
|
|
27
|
+
* Classified product search: a barcode is exact (8+ digits, no debounce), the
|
|
28
|
+
* rest waits for 2 characters and 250ms. `search` needn't be memoized.
|
|
29
|
+
*/
|
|
30
|
+
export function useProductSearch(text, search, enabled = true) {
|
|
31
|
+
const stable = useLatestSearch(search);
|
|
32
|
+
const parsed = parseProductQuery(text);
|
|
33
|
+
const run = useMemo(() => (q, signal) => {
|
|
34
|
+
const p = parseProductQuery(q);
|
|
35
|
+
return p.kind === 'empty' ? Promise.resolve([]) : stable(p, signal);
|
|
36
|
+
}, [stable]);
|
|
37
|
+
const minChars = parsed.kind === 'barcode' ? 8 : 2;
|
|
38
|
+
const delay = parsed.kind === 'barcode' ? 0 : 250;
|
|
39
|
+
const { results, loading, error, pending } = useRecordSearch(text, run, { minChars, delay, enabled });
|
|
40
|
+
const hits = useMemo(() => flattenProductHits(results), [results]);
|
|
41
|
+
return { parsed, results, hits, loading: loading || pending, error, minChars };
|
|
42
|
+
}
|
|
43
|
+
/** Stock + price at the right of a product row. `compact` → bare stock count (table cell). */
|
|
44
|
+
export function ProductHitTrailing({ hit, warehouseId, currency, compact = false, }) {
|
|
45
|
+
const { t } = useTranslation();
|
|
46
|
+
const fmt = useFormatter({ currency });
|
|
47
|
+
const price = hit.variant?.price ?? hit.product.price;
|
|
48
|
+
const stock = availableStock(hit.variant ?? hit.product, warehouseId);
|
|
49
|
+
return (_jsxs(_Fragment, { children: [stock != null &&
|
|
50
|
+
(compact ? (_jsx(Badge, { variant: "secondary", className: "font-normal tabular-nums", children: stock })) : (_jsx(Badge, { variant: stock > 0 ? 'success' : 'danger', children: stock > 0
|
|
51
|
+
? t('productPicker.inStock', { defaultValue: '{{n}} disp.', n: stock })
|
|
52
|
+
: t('productPicker.outOfStock', { defaultValue: 'Sin existencia' }) }))), price != null && _jsx("span", { className: "tabular-nums", children: fmt.money(price) })] }));
|
|
53
|
+
}
|
|
54
|
+
/** Option body: bold name (· variant), muted meta line, stock/price right. */
|
|
55
|
+
export function ProductHitRow({ hit, warehouseId, currency, compact }) {
|
|
56
|
+
const meta = productHitMeta(hit);
|
|
57
|
+
return (_jsxs("span", { className: "flex w-full min-w-0 items-center justify-between gap-3", children: [_jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block truncate font-medium", children: productHitLabel(hit) }), meta && _jsx("span", { className: "block truncate text-xs text-muted-foreground", children: meta })] }), _jsx("span", { className: "flex shrink-0 items-center gap-2", children: _jsx(ProductHitTrailing, { hit: hit, warehouseId: warehouseId, currency: currency, compact: compact }) })] }));
|
|
58
|
+
}
|
|
@@ -2,7 +2,7 @@ import { type ProductQuery, type ProductResult, type ProductVariant } from './pr
|
|
|
2
2
|
export interface ProductPickerProps {
|
|
3
3
|
/**
|
|
4
4
|
* Búsqueda: recibe la consulta ya clasificada (`barcode` | `tire_size` |
|
|
5
|
-
* `text`) para que el backend elija el índice.
|
|
5
|
+
* `text`) para que el backend elija el índice.
|
|
6
6
|
*/
|
|
7
7
|
search: (query: Exclude<ProductQuery, {
|
|
8
8
|
kind: 'empty';
|
|
@@ -20,5 +20,9 @@ export interface ProductPickerProps {
|
|
|
20
20
|
currency?: string;
|
|
21
21
|
disabled?: boolean;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* @deprecated Configuración fina de {@link RecordPicker}; para pantallas nuevas
|
|
25
|
+
* usa RecordPicker con `useProductSearch` / `ProductHitRow`.
|
|
26
|
+
*/
|
|
23
27
|
export declare function ProductPicker({ search, onSelect, warehouseId, autoSelectBarcode, allowOutOfStock, placeholder, autoFocus, currency, disabled, }: ProductPickerProps): import("react").JSX.Element;
|
|
24
28
|
//# sourceMappingURL=product-picker.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"product-picker.d.ts","sourceRoot":"","sources":["../../src/business/product-picker.tsx"],"names":[],"mappings":"AAQA,OAAO,
|
|
1
|
+
{"version":3,"file":"product-picker.d.ts","sourceRoot":"","sources":["../../src/business/product-picker.tsx"],"names":[],"mappings":"AAQA,OAAO,EAAkB,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAE7G,MAAM,WAAW,kBAAkB;IAC/B;;;OAGG;IACH,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,YAAY,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,aAAa,EAAE,CAAC,CAAA;IAC1G,8DAA8D;IAC9D,QAAQ,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,OAAO,CAAC,EAAE,cAAc,KAAK,IAAI,CAAA;IACpE,0EAA0E;IAC1E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mGAAmG;IACnG,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,yEAAyE;IACzE,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAC1B,MAAM,EACN,QAAQ,EACR,WAAW,EACX,iBAAwB,EACxB,eAAsB,EACtB,WAAW,EACX,SAAS,EACT,QAAQ,EACR,QAAQ,GACX,EAAE,kBAAkB,+BA+DpB"}
|