Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/features/inventory/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { client } from 'shared/api/client'
import type {
CreateProductPayload,
Product,
ProductCategory,
ProductFilters,
ProductPage,
ProductSummary,
Expand Down Expand Up @@ -140,9 +141,13 @@ export async function fetchProductPage(filters: ProductFilters): Promise<Product
* Alimenta los contadores de las pestañas: pide una sola fila y lee nada más
* que el `meta.total`, que el backend cuenta sobre el scope ya filtrado.
*/
export async function fetchProductCount(status?: StockStatus, search?: string): Promise<number> {
export async function fetchProductCount(
status?: StockStatus,
search?: string,
category?: ProductCategory,
): Promise<number> {
const { data } = await client.get<ApiList<ApiProductSummary>>('/products', {
params: toParams({ page: 1, perPage: 1, status, search }),
params: toParams({ page: 1, perPage: 1, status, search, category }),
})

return data.meta.total
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -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,
Expand All @@ -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 (
<LabeledField label={label} helperText={helperText}>
Expand Down
2 changes: 2 additions & 0 deletions src/features/inventory/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
12 changes: 7 additions & 5 deletions src/features/inventory/hooks/useInventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { inventoryKeys } from '../queryKeys'
import type {
CreateProductPayload,
Product,
ProductCategory,
ProductFilters,
ProductPage,
StockStatus,
Expand Down Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions src/features/inventory/pages/InventoryPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
55 changes: 52 additions & 3 deletions src/features/inventory/pages/InventoryPage.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -207,6 +238,24 @@ export function InventoryPage() {
},
}}
/>
<TextField
select
size="small"
value={category ?? ''}
onChange={(event) => changeCategory(event.target.value)}
label={page.categoryLabel}
sx={{ width: { xs: '100%', sm: 200 } }}
slotProps={{ select: { displayEmpty: true }, inputLabel: { shrink: true } }}
>
<MenuItem value="">{page.allCategories}</MenuItem>
{/* La categoría de la URL se ofrece aunque la lista no la tenga: ver
`categoryOptions`. */}
{categoryOptions(categories.data ?? [], category ?? '').map((option) => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</TextField>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
{createModal.open}
</Button>
Expand Down
6 changes: 3 additions & 3 deletions src/features/inventory/queryKeys.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions src/features/inventory/utils/categories.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
14 changes: 14 additions & 0 deletions src/features/inventory/utils/categories.ts
Original file line number Diff line number Diff line change
@@ -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]
}