From b41585aefea4ab1588bbb0276fb464862890b373 Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 2 Oct 2026 01:37:27 -0300 Subject: [PATCH] feat: [TESIS-999012] filter the catalog by category The products API filters by category and GET /products/categories exists, in its own words, so that the creation modal and the listing filter do not repeat the list, but the catalog had no category filter. A category select now sits next to the search. The choice lives in the URL, like the tab, so it can be linked and survives a reload; changing it goes back to the first page, and the tab counters count within the category. A category in the URL that the list does not have yet is still shown, so a filter is never applied without being visible. Co-Authored-By: Claude Opus 5.5 --- src/features/inventory/api.ts | 9 ++- .../CategoryField/CategoryField.tsx | 10 ++-- src/features/inventory/content.ts | 2 + src/features/inventory/hooks/useInventory.ts | 12 ++-- .../inventory/pages/InventoryPage.test.tsx | 51 +++++++++++++++++ .../inventory/pages/InventoryPage.tsx | 55 ++++++++++++++++++- src/features/inventory/queryKeys.ts | 6 +- .../inventory/utils/categories.test.ts | 28 ++++++++++ src/features/inventory/utils/categories.ts | 14 +++++ 9 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 src/features/inventory/utils/categories.test.ts create mode 100644 src/features/inventory/utils/categories.ts diff --git a/src/features/inventory/api.ts b/src/features/inventory/api.ts index 2f29a8d..7721397 100644 --- a/src/features/inventory/api.ts +++ b/src/features/inventory/api.ts @@ -3,6 +3,7 @@ import { client } from 'shared/api/client' import type { CreateProductPayload, Product, + ProductCategory, ProductFilters, ProductPage, ProductSummary, @@ -140,9 +141,13 @@ export async function fetchProductPage(filters: ProductFilters): Promise { +export async function fetchProductCount( + status?: StockStatus, + search?: string, + category?: ProductCategory, +): Promise { const { data } = await client.get>('/products', { - params: toParams({ page: 1, perPage: 1, status, search }), + params: toParams({ page: 1, perPage: 1, status, search, category }), }) return data.meta.total diff --git a/src/features/inventory/components/CategoryField/CategoryField.tsx b/src/features/inventory/components/CategoryField/CategoryField.tsx index 27409a0..7170c94 100644 --- a/src/features/inventory/components/CategoryField/CategoryField.tsx +++ b/src/features/inventory/components/CategoryField/CategoryField.tsx @@ -1,6 +1,8 @@ import { MenuItem, TextField } from '@mui/material' import { LabeledField } from 'shared/components' +import { categoryOptions } from '../../utils/categories' + import type { CategoryFieldProps } from './CategoryField.types' /** @@ -9,10 +11,8 @@ import type { CategoryFieldProps } from './CategoryField.types' * Controlado y no registrado: el modal de edición se rellena con `reset` al * abrir, y un select no controlado seguiría mostrando lo que tenía antes. * - * Las opciones salen de `GET /products/categories`. Si la API todavía no - * respondió, o el producto trae una categoría que ya no está en el - * vocabulario, ese valor se ofrece igual: sin eso el select lo mostraría vacío - * y guardar lo borraría sin que nadie lo haya pedido. + * Las opciones salen de `GET /products/categories`, más la del producto si la + * lista no la trae (ver `categoryOptions`): guardar no la borra por accidente. */ export function CategoryField({ value, @@ -22,7 +22,7 @@ export function CategoryField({ helperText, noneLabel, }: CategoryFieldProps) { - const options = value !== '' && !categories.includes(value) ? [value, ...categories] : categories + const options = categoryOptions(categories, value) return ( diff --git a/src/features/inventory/content.ts b/src/features/inventory/content.ts index e04d92d..add42de 100644 --- a/src/features/inventory/content.ts +++ b/src/features/inventory/content.ts @@ -7,6 +7,8 @@ export const inventoryCopy = { title: 'Inventario', subtitle: 'Catálogo maestro de productos y disponibilidad por depósito.', searchLabel: 'Buscar productos', + categoryLabel: 'Categoría', + allCategories: 'Todas las categorías', searchPlaceholder: 'Buscar por SKU o nombre', tableLabel: 'Catálogo de productos', empty: 'No hay productos que coincidan con el filtro.', diff --git a/src/features/inventory/hooks/useInventory.ts b/src/features/inventory/hooks/useInventory.ts index 0a874e7..3806894 100644 --- a/src/features/inventory/hooks/useInventory.ts +++ b/src/features/inventory/hooks/useInventory.ts @@ -21,6 +21,7 @@ import { inventoryKeys } from '../queryKeys' import type { CreateProductPayload, Product, + ProductCategory, ProductFilters, ProductPage, StockStatus, @@ -61,14 +62,15 @@ export function useProductPage(filters: ProductFilters) { * Los cuatro contadores de las pestañas, en paralelo. Son consultas de una sola * fila que leen nada más que el `meta.total`. * - * Respetan la búsqueda: si no lo hicieran, buscar algo inexistente dejaría la - * tabla vacía con una pestaña que sigue diciendo «Todos (1.284)». + * Respetan la búsqueda y la categoría: si no lo hicieran, buscar algo + * inexistente dejaría la tabla vacía con una pestaña que sigue diciendo + * «Todos (1.284)». */ -export function useProductCounts(search: string) { +export function useProductCounts(search: string, category?: ProductCategory) { return useQueries({ queries: CATALOG_TABS.map(({ status }) => ({ - queryKey: inventoryKeys.count(status, search), - queryFn: () => fetchProductCount(status, search), + queryKey: inventoryKeys.count(status, search, category), + queryFn: () => fetchProductCount(status, search, category), })), // Un contador que falla no puede voltear la pantalla: la tabla se ve igual // y la pestaña queda sin número. diff --git a/src/features/inventory/pages/InventoryPage.test.tsx b/src/features/inventory/pages/InventoryPage.test.tsx index 268a25b..313e89b 100644 --- a/src/features/inventory/pages/InventoryPage.test.tsx +++ b/src/features/inventory/pages/InventoryPage.test.tsx @@ -65,6 +65,57 @@ beforeEach(() => { vi.mocked(useDeleteProduct).mockReturnValue(IDLE_MUTATION as never) }) +/** Los filtros con los que la pantalla pidió la última página del catálogo. */ +function lastRequestedFilters() { + const calls = vi.mocked(useProductPage).mock.calls + + return calls[calls.length - 1][0] +} + +const categorySelect = () => screen.getByRole('combobox', { name: 'Categoría' }) + +describe('InventoryPage · the category filter', () => { + it('asks for every category when the URL asks for none', () => { + renderCatalog('/inventory') + + expect(lastRequestedFilters().category).toBeUndefined() + expect(categorySelect()).toHaveTextContent('Todas las categorías') + }) + + // Enlazable y recargable, como la pestaña. + it('opens filtered by the category the URL asks for', () => { + renderCatalog('/inventory?category=Power') + + expect(lastRequestedFilters().category).toBe('Power') + expect(categorySelect()).toHaveTextContent('Power') + }) + + it('filters by the chosen category, from the first page', async () => { + renderCatalog('/inventory') + + fireEvent.mouseDown(categorySelect()) + fireEvent.click(await screen.findByRole('option', { name: 'Electronics' })) + + expect(lastRequestedFilters()).toMatchObject({ category: 'Electronics', page: 1 }) + }) + + // Si no, la pestaña diría «Todos (1.284)» con la tabla filtrada a tres filas. + it('counts the tabs within the chosen category', () => { + renderCatalog('/inventory?category=Power') + + expect(vi.mocked(useProductCounts)).toHaveBeenLastCalledWith('', 'Power') + }) + + it('goes back to every category', async () => { + renderCatalog('/inventory?category=Power') + + fireEvent.mouseDown(categorySelect()) + fireEvent.click(await screen.findByRole('option', { name: 'Todas las categorías' })) + + expect(lastRequestedFilters().category).toBeUndefined() + }) +}) + describe('InventoryPage · the tab in the URL', () => { it('opens on every product when the URL asks for no tab', () => { renderCatalog('/inventory') diff --git a/src/features/inventory/pages/InventoryPage.tsx b/src/features/inventory/pages/InventoryPage.tsx index 0f87a35..3d4c3c2 100644 --- a/src/features/inventory/pages/InventoryPage.tsx +++ b/src/features/inventory/pages/InventoryPage.tsx @@ -1,6 +1,6 @@ import AddIcon from '@mui/icons-material/Add' import SearchIcon from '@mui/icons-material/Search' -import { Box, Button, InputAdornment, Stack, TextField, Typography } from '@mui/material' +import { Box, Button, InputAdornment, MenuItem, Stack, TextField, Typography } from '@mui/material' import { useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components' @@ -23,7 +23,8 @@ import { useWarehouses, } from '../hooks/useInventory' import type { CatalogTabId } from '../hooks/useInventory' -import type { ProductSummary } from '../types' +import type { ProductCategory, ProductSummary } from '../types' +import { categoryOptions } from '../utils/categories' const { page, createModal, tabs: tabCopy, pagination } = inventoryCopy @@ -34,6 +35,21 @@ const PER_PAGE = 20 // (TESIS-55), y un estado local no se podría enlazar ni compartir. const TAB_PARAM = 'tab' +// La categoría también vive en la URL, por el mismo motivo que la pestaña: un +// filtro que no se puede enlazar ni recargar se pierde con un F5. +const CATEGORY_PARAM = 'category' + +/** + * La categoría que pide la URL, o ninguna. No se valida contra el vocabulario: + * la lista llega de la API después del primer render, y una categoría que no + * existe devuelve cero filas, que es la respuesta honesta. + */ +function categoryFromParams(params: URLSearchParams): ProductCategory | undefined { + const requested = params.get(CATEGORY_PARAM) + + return requested === null || requested === '' ? undefined : (requested as ProductCategory) +} + /** La pestaña que pide la URL, o `all` si no pide ninguna válida. */ function tabFromParams(params: URLSearchParams): CatalogTabId { const requested = params.get(TAB_PARAM) @@ -64,6 +80,7 @@ export function InventoryPage() { const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() const tabId = tabFromParams(searchParams) + const category = categoryFromParams(searchParams) const [pageNumber, setPageNumber] = useState(1) const [search, setSearch] = useState('') const [creating, setCreating] = useState(false) @@ -79,8 +96,9 @@ export function InventoryPage() { perPage: PER_PAGE, status, search: debouncedSearch, + category, }) - const counts = useProductCounts(debouncedSearch) + const counts = useProductCounts(debouncedSearch, category) const warehouses = useWarehouses() const categories = useCategories() const createMutation = useCreateProduct() @@ -104,6 +122,19 @@ export function InventoryPage() { setPageNumber(1) } + function changeCategory(value: string) { + setSearchParams( + (current) => { + const next = new URLSearchParams(current) + if (value === '') next.delete(CATEGORY_PARAM) + else next.set(CATEGORY_PARAM, value) + return next + }, + { replace: true }, + ) + setPageNumber(1) + } + function changeSearch(value: string) { setSearch(value) setPageNumber(1) @@ -207,6 +238,24 @@ export function InventoryPage() { }, }} /> + changeCategory(event.target.value)} + label={page.categoryLabel} + sx={{ width: { xs: '100%', sm: 200 } }} + slotProps={{ select: { displayEmpty: true }, inputLabel: { shrink: true } }} + > + {page.allCategories} + {/* La categoría de la URL se ofrece aunque la lista no la tenga: ver + `categoryOptions`. */} + {categoryOptions(categories.data ?? [], category ?? '').map((option) => ( + + {option} + + ))} + diff --git a/src/features/inventory/queryKeys.ts b/src/features/inventory/queryKeys.ts index d829bdc..f0169d0 100644 --- a/src/features/inventory/queryKeys.ts +++ b/src/features/inventory/queryKeys.ts @@ -1,7 +1,7 @@ // Factory de query keys de la feature — nunca literales sueltos en los hooks, // así las invalidaciones no se desincronizan cuando aparecen más mutaciones. -import type { ProductFilters, StockStatus } from './types' +import type { ProductCategory, ProductFilters, StockStatus } from './types' export const inventoryKeys = { all: ['inventory'] as const, @@ -10,8 +10,8 @@ export const inventoryKeys = { counts: () => [...inventoryKeys.products(), 'count'] as const, // `status ?? 'all'`: sin esto, la clave de «todos» y la de un estado // indefinido serían la misma sólo por casualidad de serialización. - count: (status: StockStatus | undefined, search: string) => - [...inventoryKeys.counts(), status ?? 'all', search] as const, + count: (status: StockStatus | undefined, search: string, category?: ProductCategory) => + [...inventoryKeys.counts(), status ?? 'all', search, category ?? 'any'] as const, product: (id: number) => [...inventoryKeys.products(), 'detail', id] as const, warehouses: () => [...inventoryKeys.all, 'warehouses'] as const, categories: () => [...inventoryKeys.all, 'categories'] as const, diff --git a/src/features/inventory/utils/categories.test.ts b/src/features/inventory/utils/categories.test.ts new file mode 100644 index 0000000..efb1e42 --- /dev/null +++ b/src/features/inventory/utils/categories.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { categoryOptions } from './categories' + +describe('categoryOptions', () => { + it('offers the vocabulary of the API as it comes', () => { + expect(categoryOptions(['Electronics', 'Power'], 'Power')).toEqual(['Electronics', 'Power']) + }) + + it('keeps the current value when the list does not have it', () => { + expect(categoryOptions(['Electronics'], 'Vintage')).toEqual(['Vintage', 'Electronics']) + }) + + // Primer render: la lista todavía no llegó y el filtro de la URL ya aplica. + it('offers the current value before the list arrives', () => { + expect(categoryOptions([], 'Power')).toEqual(['Power']) + }) + + it('adds nothing for no category', () => { + expect(categoryOptions(['Electronics'], '')).toEqual(['Electronics']) + }) + + it('does not hand back the array it received', () => { + const categories = ['Electronics'] + + expect(categoryOptions(categories, '')).not.toBe(categories) + }) +}) diff --git a/src/features/inventory/utils/categories.ts b/src/features/inventory/utils/categories.ts new file mode 100644 index 0000000..7e795ce --- /dev/null +++ b/src/features/inventory/utils/categories.ts @@ -0,0 +1,14 @@ +/** + * Las opciones de un select de categoría: el vocabulario de la API, más el + * valor actual si la lista no lo trae. + * + * Pasa en dos casos reales: la lista todavía no llegó (primer render), o la + * categoría salió del vocabulario después de asignarse. En los dos, sin esto + * el select se mostraría vacío mientras el dato sigue ahí —un filtro aplicado + * que no se ve, o un guardado que borra la categoría sin que nadie lo pida—. + */ +export function categoryOptions(categories: readonly string[], current: string): string[] { + return current !== '' && !categories.includes(current) + ? [current, ...categories] + : [...categories] +}