diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 531b040..3774f4c 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -308,14 +308,14 @@ features/[nombre]/ **Features existentes:** -| Feature | Contenido | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dashboard` | Panel de operación (`/dashboard`) y la sección de Integraciones (`/integrations`, detrás del feature flag `integrations` del tenant). Comparten `useInfraHealth` e `IntegrationNodeList`. El panel suma la tarjeta de alertas de inventario, la carga por depósito y la tabla de órdenes recientes (`useInventoryAlerts`, `WarehouseLoadCard`, `useRecentOrders`, `RecentOrdersTable`) | -| `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks | -| `home` | Landing de la app (`/`) | -| `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` | -| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los tres pasos del alta manual (`/orders/new`: cliente y productos; `/orders/new/shipping`: origen y destino; `/orders/new/carrier`: cotización y confirmación) | -| `reports` | Reportes — analítica de la operación (`/reports`, S14): métricas del período, curva de despacho, nivel de servicio por operador y anomalías recientes. **Sin endpoint de agregados todavía**: la pantalla muestra el dataset de muestra del diseño y lo dice junto al título (ver `api.ts`) | +| Feature | Contenido | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dashboard` | Panel de operación (`/dashboard`) y la sección de Integraciones (`/integrations`, detrás del feature flag `integrations` del tenant). Comparten `useInfraHealth` e `IntegrationNodeList`. El panel suma la tarjeta de alertas de inventario, la carga por depósito y la tabla de órdenes recientes (`useInventoryAlerts`, `WarehouseLoadCard`, `useRecentOrders`, `RecentOrdersTable`) | +| `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks | +| `home` | Landing de la app (`/`) | +| `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` | +| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los tres pasos del alta manual (`/orders/new`: cliente y productos, con el buscador filtrando contra el `search` del backend; `/orders/new/shipping`: origen y destino; `/orders/new/carrier`: cotización y confirmación) | +| `reports` | Reportes — analítica de la operación (`/reports`, S14): métricas del período, curva de despacho, nivel de servicio por operador y anomalías recientes. **Sin endpoint de agregados todavía**: la pantalla muestra el dataset de muestra del diseño y lo dice junto al título (ver `api.ts`) | **`orders` — piezas y por qué:** diff --git a/src/features/orders/api.test.ts b/src/features/orders/api.test.ts index d4d9883..0d51dc5 100644 --- a/src/features/orders/api.test.ts +++ b/src/features/orders/api.test.ts @@ -3,9 +3,11 @@ import { client } from 'shared/api/client' import { describe, expect, it, vi } from 'vitest' import { + CATALOG_MATCHES, createOrder, createOrderShipment, dispatchShipment, + fetchCatalogProducts, fetchOrder, fetchOrderShipment, fetchProductStocks, @@ -42,6 +44,45 @@ const SHIPMENT = { ], } +describe('fetchCatalogProducts', () => { + function capture() { + const sent: { params?: Record }[] = [] + vi.spyOn(client, 'get').mockImplementation((_url: string, config?: unknown) => { + sent.push(config as { params?: Record }) + return Promise.resolve(respond({ data: [], meta: { page: 1, per_page: 20, total: 0 } })) + }) + + return sent + } + + // El filtro lo hace el backend desde TESIS-125: lo que esta capa tiene que + // garantizar es que el término llegue. + it('sends the term as the search parameter', async () => { + const sent = capture() + + await fetchCatalogProducts('cable') + + expect(sent[0].params).toEqual({ page: 1, per_page: CATALOG_MATCHES, search: 'cable' }) + }) + + it('trims the term before sending it', async () => { + const sent = capture() + + await fetchCatalogProducts(' cable ') + + expect(sent[0].params).toMatchObject({ search: 'cable' }) + }) + + // Un `search` vacío haría que el backend filtre por cadena vacía: no viaja. + it('omits the parameter when nothing was typed', async () => { + const sent = capture() + + await fetchCatalogProducts(' ') + + expect(sent[0].params).toEqual({ page: 1, per_page: CATALOG_MATCHES }) + }) +}) + describe('fetchOrderShipment', () => { it('asks the list for two rows, so a second shipment cannot hide past the page', async () => { const get = vi.spyOn(client, 'get').mockResolvedValueOnce(shipmentList([])) diff --git a/src/features/orders/api.ts b/src/features/orders/api.ts index 95b334a..4d34eb4 100644 --- a/src/features/orders/api.ts +++ b/src/features/orders/api.ts @@ -297,21 +297,28 @@ export function fetchOrderCount(status?: OrderStatus, search?: string): Promise< } /** - * Cuántos productos trae el buscador del alta manual. Es el máximo que la API - * permite por página (`per_page.clamp(1, 100)`). + * Cuántas coincidencias muestra el buscador del alta manual. * - * `GET /products` no tiene parámetro de búsqueda —el index sólo pagina—, así - * que el filtro por SKU o nombre corre del lado del cliente sobre esta página. - * Un catálogo de más de cien productos deja los últimos fuera del buscador; - * cuando eso pase, la salida es un `search` en el backend, no una segunda - * página acá. + * Ya no es «el catálogo entero»: desde TESIS-62 el backend filtra, así que lo + * que llega son los productos que matchean lo tipeado, y veinte alcanzan de + * sobra para elegir uno. Antes se pedían cien y se filtraba en memoria, y un + * catálogo más grande dejaba productos que el buscador no encontraba nunca, + * sin que el operador pudiera distinguirlo de «no existe». */ -export const CATALOG_PAGE_SIZE = 100 +export const CATALOG_MATCHES = 20 -/** El catálogo de la empresa, para el buscador del paso 1 del alta manual. */ -export async function fetchCatalogProducts(): Promise { +/** + * Los productos que matchean el término, para el buscador del alta manual. + * + * Con el término vacío devuelve la primera página: el buscador recién abierto + * muestra algo en vez de nada, y el operador acota tipeando. + */ +export async function fetchCatalogProducts(search: string): Promise { + const term = search.trim() const { data } = await client.get<{ data: ApiCatalogProduct[]; meta: ApiListMeta }>('/products', { - params: { page: 1, per_page: CATALOG_PAGE_SIZE }, + // Un `search` vacío no viaja: el backend lo trataría como un filtro por + // cadena vacía. Mismo criterio que `toFilters` del listado de órdenes. + params: { page: 1, per_page: CATALOG_MATCHES, ...(term === '' ? {} : { search: term }) }, }) return data.data.map((product) => ({ diff --git a/src/features/orders/components/NewLineToolbar.tsx b/src/features/orders/components/NewLineToolbar.tsx index bb41306..067f5e4 100644 --- a/src/features/orders/components/NewLineToolbar.tsx +++ b/src/features/orders/components/NewLineToolbar.tsx @@ -17,6 +17,8 @@ export interface NewLineToolbarProps { warehouseId: number | null onWarehouseChange: (warehouseId: number) => void onAdd: (item: OrderDraftItem) => void + /** Lo que se tipeó en el buscador: de eso sale la consulta al catálogo. */ + onSearchChange: (search: string) => void disabled: boolean } @@ -37,6 +39,7 @@ export function NewLineToolbar({ warehouseId, onWarehouseChange, onAdd, + onSearchChange, disabled, }: NewLineToolbarProps) { if (disabled) return null @@ -74,6 +77,7 @@ export function NewLineToolbar({ products={products} loading={productsLoading} addedIds={addedIds} + onSearchChange={onSearchChange} onAdd={onAdd} /> )} diff --git a/src/features/orders/components/OrderEditForm/OrderEditForm.tsx b/src/features/orders/components/OrderEditForm/OrderEditForm.tsx index 0bda9bc..379c1d8 100644 --- a/src/features/orders/components/OrderEditForm/OrderEditForm.tsx +++ b/src/features/orders/components/OrderEditForm/OrderEditForm.tsx @@ -4,6 +4,7 @@ import { Alert, Box, Button, Stack } from '@mui/material' import { useMemo, useState } from 'react' import { useForm, useWatch } from 'react-hook-form' import { useNavigate } from 'react-router-dom' +import { useDebouncedValue } from 'shared/hooks/useDebouncedValue' import { notify } from 'shared/store' import type { OrderDraftItem } from 'shared/store' @@ -92,7 +93,11 @@ export function OrderEditForm({ const [newLineWarehouse, setNewLineWarehouse] = useState(() => mostUsedWarehouse(original)) const warehouses = useOriginWarehouses() - const catalog = useCatalogProducts() + // El catálogo lo filtra el backend (TESIS-125), igual que en el paso 1 del + // alta: lo tipeado actualiza el campo en el acto y viaja recién cuando la + // persona deja de escribir. + const [productSearch, setProductSearch] = useState('') + const catalog = useCatalogProducts(useDebouncedValue(productSearch)) const provinces = useProvinces() const stocks = useProductStocks([ ...new Set([...original, ...lines].map((line) => line.productId)), @@ -228,12 +233,17 @@ export function OrderEditForm({ toolbar={ line.productId))} warehouses={warehouses.data ?? []} warehouseId={newLineWarehouse} onWarehouseChange={setNewLineWarehouse} onAdd={addLine} + onSearchChange={setProductSearch} disabled={readOnly} /> } diff --git a/src/features/orders/components/ProductPicker/ProductPicker.tsx b/src/features/orders/components/ProductPicker/ProductPicker.tsx index f1b818f..f1d18e5 100644 --- a/src/features/orders/components/ProductPicker/ProductPicker.tsx +++ b/src/features/orders/components/ProductPicker/ProductPicker.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { formatCount, ordersCopy } from '../../content' import type { CatalogProduct } from '../../types' -import { filterCatalog, isDraftItemValid } from '../../utils/draft' +import { isDraftItemValid } from '../../utils/draft' import { OptionBody, PickerForm, PriceSlot, QuantitySlot, SearchSlot } from './ProductPicker.styles' import type { ProductPickerProps } from './ProductPicker.types' @@ -29,7 +29,13 @@ const MONO = { typography: 'dataMono' } * misma que valida las filas ya cargadas, y así vive en un solo lugar en vez * de repetirse en un schema. */ -export function ProductPicker({ products, loading = false, addedIds, onAdd }: ProductPickerProps) { +export function ProductPicker({ + products, + loading = false, + addedIds, + onSearchChange, + onAdd, +}: ProductPickerProps) { const [product, setProduct] = useState(null) const [quantity, setQuantity] = useState(INITIAL_QUANTITY) const [unitPrice, setUnitPrice] = useState(INITIAL_PRICE) @@ -52,6 +58,8 @@ export function ProductPicker({ products, loading = false, addedIds, onAdd }: Pr }) // Lista para el siguiente SKU: el foco queda en el buscador porque el // usuario va a tipear otro. + // El buscador se vacía solo: al volver `product` a null, MUI limpia el + // campo y avisa con un valor vacío, que `onInputChange` sí propaga. setProduct(null) setQuantity(INITIAL_QUANTITY) setUnitPrice(INITIAL_PRICE) @@ -69,9 +77,26 @@ export function ProductPicker({ products, loading = false, addedIds, onAdd }: Pr options={products} value={product} onChange={(_event, value) => setProduct(value)} - // El filtro es el mismo que se prueba en `utils/draft.ts`, no el de - // MUI: así busca en el SKU y en el nombre con la misma regla. - filterOptions={(options, state) => filterCatalog(options, state.inputValue)} + // Sólo lo que la persona tipea es una búsqueda. Al elegir una opción + // MUI escribe su etiqueta en el campo y avisa con `reason: 'reset'`: + // si eso viajara al backend —y viajaba— se pediría el catálogo por + // «PX-9021-LRG · Router industrial…», que no matchea ni por SKU ni + // por nombre, y la lista quedaría vacía la próxima vez que se abra el + // buscador. + // + // `value === ''` sí se propaga: es la cruz de borrar y el reinicio + // que hace MUI cuando la línea se agrega y el producto vuelve a null. + onInputChange={(_event, value, reason) => { + if (reason === 'input' || value === '') onSearchChange(value) + }} + // Las opciones se muestran tal como llegaron: ya vienen filtradas por + // el backend, que busca por SKU y por nombre. Filtrar de nuevo acá no + // agregaría nada y sí quitaría: con `keepPreviousData` la lista sigue + // mostrando las coincidencias de la búsqueda anterior mientras llega + // la nueva, y el filtro de MUI —que compara contra lo ya tipeado— las + // escondería tras un «Ningún producto coincide.» que dura lo que + // tarda el request. + filterOptions={(options) => options} getOptionLabel={(option) => `${option.sku} · ${option.name}`} getOptionKey={(option) => option.id} isOptionEqualToValue={(option, value) => option.id === value.id} diff --git a/src/features/orders/components/ProductPicker/ProductPicker.types.ts b/src/features/orders/components/ProductPicker/ProductPicker.types.ts index 1f8380c..5d45e6c 100644 --- a/src/features/orders/components/ProductPicker/ProductPicker.types.ts +++ b/src/features/orders/components/ProductPicker/ProductPicker.types.ts @@ -3,12 +3,19 @@ import type { OrderDraftItem } from 'shared/store' import type { CatalogProduct } from '../../types' export interface ProductPickerProps { - /** El catálogo completo; el filtro por SKU o nombre corre acá adentro. */ + /** Las coincidencias que devolvió el backend para `search`. */ products: CatalogProduct[] - /** Mientras el catálogo viaja el buscador avisa en vez de decir "sin resultados". */ + /** Mientras la búsqueda viaja el buscador avisa en vez de decir "sin resultados". */ loading?: boolean /** Productos que ya tienen fila: se ven pero no se pueden volver a elegir. */ addedIds: ReadonlySet + /** + * Lo que se tipeó, hacia afuera: de eso sale la consulta, y el componente no + * decide cuándo se busca ni con qué demora. El texto del campo lo maneja MUI + * —controlarlo desde afuera hacía que la etiqueta de la opción elegida se + * mandara como término—. + */ + onSearchChange: (search: string) => void /** La línea armada con producto, cantidad y precio unitario. */ onAdd: (item: OrderDraftItem) => void } diff --git a/src/features/orders/content.ts b/src/features/orders/content.ts index befe747..29ac638 100644 --- a/src/features/orders/content.ts +++ b/src/features/orders/content.ts @@ -195,7 +195,7 @@ export const ordersCopy = { searchLabel: 'Buscar por SKU o nombre', searchPlaceholder: 'SKU o nombre del producto', noMatches: 'Ningún producto coincide.', - loadingCatalog: 'Cargando el catálogo…', + loadingCatalog: 'Buscando…', catalogError: 'No pudimos cargar el catálogo de productos.', retry: 'Reintentar', /** El producto ya tiene su fila: se edita ahí, no se agrega dos veces. */ diff --git a/src/features/orders/hooks/useCatalogProducts.test.tsx b/src/features/orders/hooks/useCatalogProducts.test.tsx new file mode 100644 index 0000000..099cf7c --- /dev/null +++ b/src/features/orders/hooks/useCatalogProducts.test.tsx @@ -0,0 +1,88 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import * as api from '../api' +import { catalogKeys } from '../queryKeys' + +import { useCatalogProducts } from './useCatalogProducts' + +// Un cliente por ejemplo: la caché no puede filtrarse de un test al siguiente. +function makeWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +function newClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('useCatalogProducts', () => { + it('asks the backend for the term it was given', async () => { + const fetchCatalog = vi.spyOn(api, 'fetchCatalogProducts').mockResolvedValue([]) + + const { result } = renderHook(() => useCatalogProducts('cable'), { + wrapper: makeWrapper(newClient()), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(fetchCatalog).toHaveBeenCalledWith('cable') + }) + + // «cab» y «cab » son la misma búsqueda. Si el término entrara sin recortar en + // la clave serían dos entradas de caché y dos requests con la misma respuesta. + it('treats a term with surrounding spaces as the same search', async () => { + const fetchCatalog = vi.spyOn(api, 'fetchCatalogProducts').mockResolvedValue([]) + const queryClient = newClient() + + const { result } = renderHook(() => useCatalogProducts(' cable '), { + wrapper: makeWrapper(queryClient), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(fetchCatalog).toHaveBeenCalledWith('cable') + }) + + it('files it in the cache under the trimmed term', async () => { + vi.spyOn(api, 'fetchCatalogProducts').mockResolvedValue([]) + const queryClient = newClient() + + const { result } = renderHook(() => useCatalogProducts('cable '), { + wrapper: makeWrapper(queryClient), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(queryClient.getQueryData(catalogKeys.products('cable'))).toEqual([]) + }) + + // Mientras llega la búsqueda nueva se siguen mostrando las coincidencias de + // la anterior: es lo que evita que la lista parpadee vacía entre pulsaciones. + it('keeps the previous matches while the new search travels', async () => { + const product = { + id: 1, + sku: 'CAB-1', + name: 'Cable', + category: 'Electronics', + weight: 0.2, + totalStock: 5, + } + vi.spyOn(api, 'fetchCatalogProducts').mockResolvedValue([product]) + const queryClient = newClient() + + const { result, rerender } = renderHook((term: string) => useCatalogProducts(term), { + wrapper: makeWrapper(queryClient), + initialProps: 'cab', + }) + await waitFor(() => expect(result.current.data).toEqual([product])) + + rerender('cable') + + expect(result.current.data).toEqual([product]) + }) +}) diff --git a/src/features/orders/hooks/useCatalogProducts.ts b/src/features/orders/hooks/useCatalogProducts.ts index 3420f29..c502c6f 100644 --- a/src/features/orders/hooks/useCatalogProducts.ts +++ b/src/features/orders/hooks/useCatalogProducts.ts @@ -1,17 +1,30 @@ -import { useQuery } from '@tanstack/react-query' +import { keepPreviousData, useQuery } from '@tanstack/react-query' import { fetchCatalogProducts } from '../api' import { catalogKeys } from '../queryKeys' import type { CatalogProduct } from '../types' /** - * El catálogo completo para el buscador del alta manual. Se pide una vez y el - * filtro por SKU o nombre corre en memoria (ver `filterCatalog`): la API no - * busca, así que un request por tecla no tendría nada que devolver distinto. + * Los productos que matchean lo que se tipeó en el buscador del alta manual. + * + * Filtra el backend (`search` de `GET /products`, TESIS-62) y no el cliente: + * traerse una página y filtrarla en memoria dejaba fuera del buscador a todo + * producto más allá del corte de la API, y el operador no tenía cómo + * distinguir «no existe» de «está más allá de la primera página». + * + * `keepPreviousData` es lo que evita que la lista parpadee vacía entre + * pulsaciones: mientras llega la búsqueda nueva se siguen mostrando las + * coincidencias de la anterior. */ -export function useCatalogProducts() { +export function useCatalogProducts(search: string) { + // Recortado acá y no sólo al armar el request: la clave de caché se arma con + // el mismo término que viaja, así que «cab» y «cab » son una sola entrada y + // un solo pedido, en vez de dos que devuelven lo mismo. + const term = search.trim() + return useQuery({ - queryKey: catalogKeys.products(), - queryFn: fetchCatalogProducts, + queryKey: catalogKeys.products(term), + queryFn: () => fetchCatalogProducts(term), + placeholderData: keepPreviousData, }) } diff --git a/src/features/orders/pages/NewOrderPage.test.tsx b/src/features/orders/pages/NewOrderPage.test.tsx index 38e61ab..d098707 100644 --- a/src/features/orders/pages/NewOrderPage.test.tsx +++ b/src/features/orders/pages/NewOrderPage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { useOrderDraftStore } from 'shared/store' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -30,15 +30,42 @@ const CATALOG: CatalogProduct[] = [ }, ] -function mockCatalog(state: { data?: CatalogProduct[]; isPending?: boolean; isError?: boolean }) { +function mockCatalog(state: { + data?: CatalogProduct[] + isPending?: boolean + isFetching?: boolean + isError?: boolean +}) { vi.mocked(useCatalogProducts).mockReturnValue({ data: state.data, isPending: state.isPending ?? false, + isFetching: state.isFetching ?? false, isError: state.isError ?? false, refetch: vi.fn(), } as never) } +/** Con qué término se pidió el catálogo la última vez. */ +function lastSearch(): string { + const calls = vi.mocked(useCatalogProducts).mock.calls + + return calls[calls.length - 1][0] +} + +/** Todos los términos con los que se pidió el catálogo. */ +function searchedTerms(): string[] { + return vi.mocked(useCatalogProducts).mock.calls.map((call) => call[0]) +} + +// Lo tipeado viaja con un debounce de 300 ms (`useDebouncedValue`). Cuando lo +// que se quiere afirmar es que algo NO se buscó, hay que dejarlo vencer: si no, +// el ejemplo pasa por llegar antes y no por el comportamiento. +const SETTLE_MS = 400 + +function settle() { + return act(() => new Promise((resolve) => setTimeout(resolve, SETTLE_MS))) +} + function renderPage() { return renderWithTheme( @@ -125,15 +152,84 @@ describe('NewOrderPage', () => { await waitFor(() => expect(nextButton()).toBeEnabled()) }) - it('finds a product by its name as well as by its sku', async () => { + // El filtro es del backend (TESIS-125): lo que la pantalla tiene que hacer es + // mandarle lo tipeado. Buscar en memoria dejaba fuera del buscador todo + // producto más allá del corte de la API, sin que se distinguiera de «no + // existe». + it('asks the backend for what was typed', async () => { renderPage() type(search(), 'sensor') + await waitFor(() => expect(lastSearch()).toBe('sensor')) + }) + + it('does not go to the backend on every keystroke', async () => { + renderPage() + + type(search(), 's') + type(search(), 'se') + type(search(), 'sen') + + // Las tres pulsaciones colapsan en una sola búsqueda. + await waitFor(() => expect(lastSearch()).toBe('sen')) + expect( + vi.mocked(useCatalogProducts).mock.calls.filter((call) => call[0] === 'se'), + ).toHaveLength(0) + }) + + // Lo que llega ya viene filtrado: la pantalla no vuelve a decidir qué mostrar. + // + // El término no aparece en ninguna etiqueta a propósito: con uno que sí + // apareciera —«sensor»— la opción se vería igual con el filtro que MUI trae + // de fábrica, y el ejemplo pasaría aunque se quitara `filterOptions`. + it('lists what the backend returned without filtering it again', async () => { + mockCatalog({ data: [CATALOG[1]] }) + renderPage() + + type(search(), 'zzz') + expect(await screen.findByRole('option', { name: /PX-1185-MED/ })).toBeInTheDocument() expect(screen.queryByRole('option', { name: /PX-9021-LRG/ })).not.toBeInTheDocument() }) + // Al elegir una opción, MUI escribe su etiqueta en el campo. Eso no es una + // búsqueda: mandarla al backend pedía el catálogo por «PX-9021-LRG · Router + // industrial…», que no matchea ni por SKU ni por nombre, y dejaba la lista + // vacía para la próxima vez que se abriera el buscador. + // + // El `settle()` no es decorativo: lo que viaja pasa por un debounce, así que + // sin esperar a que venza el ejemplo pasa aunque la etiqueta sí se mande. + it('does not search for the label of the option that was picked', async () => { + renderPage() + + type(search(), 'PX-9021') + await waitFor(() => expect(lastSearch()).toBe('PX-9021')) + fireEvent.click(await screen.findByRole('option', { name: /PX-9021/i })) + await settle() + + expect(searchedTerms().some((term) => term.includes('Router industrial'))).toBe(false) + }) + + it('keeps searching for what was typed after picking an option', async () => { + renderPage() + + type(search(), 'PX-9021') + await waitFor(() => expect(lastSearch()).toBe('PX-9021')) + fireEvent.click(await screen.findByRole('option', { name: /PX-9021/i })) + await settle() + + expect(lastSearch()).toBe('PX-9021') + }) + + it('empties the search once the line is added', async () => { + renderPage() + + await addProduct('PX-9021', '2', '1000') + + await waitFor(() => expect(lastSearch()).toBe('')) + }) + it('will not add a line until quantity and price are valid', async () => { renderPage() diff --git a/src/features/orders/pages/NewOrderPage.tsx b/src/features/orders/pages/NewOrderPage.tsx index 799cfb3..1c3b8ae 100644 --- a/src/features/orders/pages/NewOrderPage.tsx +++ b/src/features/orders/pages/NewOrderPage.tsx @@ -2,9 +2,11 @@ import { zodResolver } from '@hookform/resolvers/zod' import ArrowForwardIcon from '@mui/icons-material/ArrowForward' import CloseIcon from '@mui/icons-material/Close' import { Alert, Button, Stack } from '@mui/material' +import { useState } from 'react' import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router-dom' import { PageWrapper } from 'shared/components' +import { useDebouncedValue } from 'shared/hooks/useDebouncedValue' import { useOrderDraftStore } from 'shared/store' import { CustomerFieldsCard, customerSchema } from '../components/CustomerFieldsCard' @@ -54,7 +56,11 @@ export function NewOrderPage() { const removeItem = useOrderDraftStore((state) => state.removeItem) const clearDraft = useOrderDraftStore((state) => state.clearDraft) - const catalog = useCatalogProducts() + // Lo tipeado actualiza el campo en el acto; lo que viaja a la API espera a + // que la persona deje de escribir. Sin esto el buscador dispara un request + // por pulsación y descarta casi todos. + const [search, setSearch] = useState('') + const catalog = useCatalogProducts(useDebouncedValue(search)) const { register, @@ -109,8 +115,9 @@ export function NewOrderPage() { toolbar={ } diff --git a/src/features/orders/pages/OrderEditPage.test.tsx b/src/features/orders/pages/OrderEditPage.test.tsx index c22a80a..b9511dd 100644 --- a/src/features/orders/pages/OrderEditPage.test.tsx +++ b/src/features/orders/pages/OrderEditPage.test.tsx @@ -345,6 +345,26 @@ describe('OrderEditPage', { timeout: 15_000 }, () => { expect(vi.mocked(useUpdateOrder)).toHaveBeenLastCalledWith(8829, '"v1"') }) + // La pantalla de edición comparte el buscador con el paso 1 del alta, así que + // también busca contra el backend (TESIS-125). El depósito se elige primero: + // sin él no se muestra el buscador. + it('asks the backend for what was typed in the line search', async () => { + renderPage() + + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Depósito de la línea nueva' })) + fireEvent.click(await screen.findByRole('option', { name: /CD Ezeiza/ })) + + const productSearch = screen.getByRole('combobox', { name: 'Buscar por SKU o nombre' }) + fireEvent.focus(productSearch) + fireEvent.change(productSearch, { target: { value: 'sensor' } }) + + await waitFor(() => { + const calls = vi.mocked(useCatalogProducts).mock.calls + + expect(calls[calls.length - 1][0]).toBe('sensor') + }) + }) + it('goes back to the detail without saving on Discard', async () => { renderPage() diff --git a/src/features/orders/queryKeys.ts b/src/features/orders/queryKeys.ts index 540fdfc..fa266e7 100644 --- a/src/features/orders/queryKeys.ts +++ b/src/features/orders/queryKeys.ts @@ -49,7 +49,9 @@ export const quoteKeys = { * otra. */ export const catalogKeys = { - products: () => ['inventory', 'products', 'catalog'] as const, + // El término entra en la clave: cada búsqueda es una consulta distinta, y así + // volver a un término ya tipeado sale de la caché en vez de la red. + products: (search: string) => ['inventory', 'products', 'catalog', search] as const, // Las dos siguientes cuelgan de las claves de `inventoryKeys.product(id)` y // `inventoryKeys.warehouses()`, así un ajuste de stock o un depósito nuevo // también refrescan el paso 2. Llevan un segmento más y no son la misma diff --git a/src/features/orders/utils/draft.test.ts b/src/features/orders/utils/draft.test.ts index 5bf3f3d..9ba5585 100644 --- a/src/features/orders/utils/draft.test.ts +++ b/src/features/orders/utils/draft.test.ts @@ -1,36 +1,7 @@ import type { OrderDraftItem } from 'shared/store' import { describe, expect, it } from 'vitest' -import type { CatalogProduct } from '../types' - -import { canProceed, draftSubtotal, draftWeight, filterCatalog, isDraftItemValid } from './draft' - -const CATALOG: CatalogProduct[] = [ - { - id: 1, - sku: 'PX-9021-LRG', - name: 'Router industrial', - category: 'Electronics', - weight: 1.2, - totalStock: 40, - }, - { - id: 2, - sku: 'PX-1185-MED', - name: 'Sensor de presión X4', - category: 'Machinery', - weight: 0.4, - totalStock: 0, - }, - { - id: 3, - sku: 'CB-4420-XT', - name: 'Precinto de carga', - category: null, - weight: 2.5, - totalStock: 12, - }, -] +import { canProceed, draftSubtotal, draftWeight, isDraftItemValid } from './draft' function item(overrides: Partial = {}): OrderDraftItem { return { @@ -45,20 +16,6 @@ function item(overrides: Partial = {}): OrderDraftItem { } } -describe('filterCatalog', () => { - it('matches the sku ignoring case', () => { - expect(filterCatalog(CATALOG, 'px-11').map((p) => p.id)).toEqual([2]) - }) - - it('matches the name ignoring case', () => { - expect(filterCatalog(CATALOG, 'PRECINTO').map((p) => p.id)).toEqual([3]) - }) - - it('returns the whole catalog for a blank term', () => { - expect(filterCatalog(CATALOG, ' ')).toHaveLength(3) - }) -}) - describe('isDraftItemValid', () => { it('accepts a whole quantity with a positive price', () => { expect(isDraftItemValid({ quantity: 1, unitPrice: 0.01 })).toBe(true) diff --git a/src/features/orders/utils/draft.ts b/src/features/orders/utils/draft.ts index e599a11..13181f2 100644 --- a/src/features/orders/utils/draft.ts +++ b/src/features/orders/utils/draft.ts @@ -1,27 +1,10 @@ import type { OrderDraftItem } from 'shared/store' -import type { CatalogProduct } from '../types' - import { lineSubtotal } from './payment' // Las reglas del paso 1 del alta manual, fuera de los componentes para que se // puedan probar sin montar la pantalla. -/** - * Los productos cuyo SKU o nombre contienen el término, sin distinguir - * mayúsculas. Con el término vacío devuelve el catálogo entero: el buscador - * recién abierto muestra todo, no nada. - */ -export function filterCatalog(products: CatalogProduct[], term: string): CatalogProduct[] { - const needle = term.trim().toLowerCase() - if (needle === '') return products - - return products.filter( - (product) => - product.sku.toLowerCase().includes(needle) || product.name.toLowerCase().includes(needle), - ) -} - /** * Una línea válida para enviar: al menos una unidad entera y un precio mayor a * cero. Es lo que `Orders::CreateOrder` exige por ítem (`quantity` y