From 0c3bc9edac86e29732c667d40dc1c3fc4074d51a Mon Sep 17 00:00:00 2001 From: Tomas Martin Date: Sun, 4 Oct 2026 21:07:38 -0300 Subject: [PATCH] feat: [TESIS-164] search the shipments listing by tracking and order The listing shipped with tabs and pagination but no way in. When a buyer calls, the operator holds the tracking number the courier issued or the id the sales channel gave the order, and neither could be typed anywhere. A search box goes in the header, debounced so a sixteen-character tracking number is one request and not sixteen. The term travels to the API as the `search` param TESIS-164 added on the backend: the filtering is the database's job, because filtering here would only see the page already fetched. Three things the term has to respect: - It goes into the tab counters too. Without that the tabs would keep reporting how many shipments the company has while the table shows three. - It resets the page. Searching from page 3 returned an empty page, since the result of a search fits in one. - It narrows the active tab instead of replacing it. The empty table now says which emptiness it is: a tab with no shipments and a search with no matches read the same to the code and not to the person, so the page picks the message and the table takes it as a prop. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp --- src/features/shipments/api.test.ts | 69 +++++++++++++++ src/features/shipments/api.ts | 14 +++- .../ShipmentsTable/ShipmentsTable.tsx | 3 +- .../ShipmentsTable/ShipmentsTable.types.ts | 6 ++ src/features/shipments/content.ts | 5 ++ src/features/shipments/hooks/useShipments.ts | 6 +- .../shipments/pages/ShipmentsPage.test.tsx | 83 ++++++++++++++++++- .../shipments/pages/ShipmentsPage.tsx | 76 +++++++++++++---- src/features/shipments/queryKeys.ts | 7 +- src/features/shipments/types.ts | 2 + 10 files changed, 246 insertions(+), 25 deletions(-) create mode 100644 src/features/shipments/api.test.ts diff --git a/src/features/shipments/api.test.ts b/src/features/shipments/api.test.ts new file mode 100644 index 0000000..c170009 --- /dev/null +++ b/src/features/shipments/api.test.ts @@ -0,0 +1,69 @@ +import type { AxiosResponse } from 'axios' +import { client } from 'shared/api/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { fetchShipmentCount, fetchShipmentPage } from './api' + +// Sólo el `data` importa: la frontera no lee headers ni status de estas respuestas. +function respond(data: unknown): AxiosResponse { + return { data, headers: {} } as AxiosResponse +} + +/** Los query params con los que se llamó al endpoint. */ +function capture() { + const sent: Record[] = [] + vi.spyOn(client, 'get').mockImplementation((_url: string, config?: unknown) => { + sent.push((config as { params: Record }).params) + + return Promise.resolve(respond({ data: [], meta: { page: 1, per_page: 20, total: 0 } })) + }) + + return sent +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +// El filtro lo hace el backend (TESIS-164): lo que esta capa tiene que +// garantizar es que el término llegue, y que el vacío no. +describe('fetchShipmentPage', () => { + it('sends the term as the search parameter', async () => { + const sent = capture() + await fetchShipmentPage({ page: 1, perPage: 20, search: 'AND-9920' }) + + expect(sent[0].search).toBe('AND-9920') + }) + + // Un `search=` en blanco ensucia la URL y la clave de caché sin cambiar la + // respuesta: el backend corta antes de armar la condición. + it('omits search when there is no term', async () => { + const sent = capture() + await fetchShipmentPage({ page: 1, perPage: 20, search: '' }) + + expect(sent[0]).not.toHaveProperty('search') + }) + + it('keeps the status filter alongside the term', async () => { + const sent = capture() + await fetchShipmentPage({ page: 1, perPage: 20, status: 'pending', search: 'AND' }) + + expect(sent[0]).toMatchObject({ status: 'pending', search: 'AND' }) + }) +}) + +describe('fetchShipmentCount', () => { + it('counts with the term applied', async () => { + const sent = capture() + await fetchShipmentCount('pending', 'AND') + + expect(sent[0]).toMatchObject({ status: 'pending', search: 'AND', per_page: 1 }) + }) + + it('omits search when there is no term', async () => { + const sent = capture() + await fetchShipmentCount('pending') + + expect(sent[0]).not.toHaveProperty('search') + }) +}) diff --git a/src/features/shipments/api.ts b/src/features/shipments/api.ts index 6583793..79f28de 100644 --- a/src/features/shipments/api.ts +++ b/src/features/shipments/api.ts @@ -50,13 +50,16 @@ function toShipment(shipment: ApiShipmentSummary): ShipmentSummary { } // Un filtro vacío no viaja: mandar `status=` en blanco haría que el backend -// filtre por el string vacío y devuelva cero filas. -function toParams({ page, perPage, status, orderId }: ShipmentFilters) { +// filtre por el string vacío y devuelva cero filas. Con `search` el backend sí +// tolera el vacío —corta antes de armar la condición—, pero mandarlo igual +// ensucia la URL y la clave de caché, así que se omite por el mismo criterio. +function toParams({ page, perPage, status, orderId, search }: ShipmentFilters) { return { page, per_page: perPage, ...(status === undefined ? {} : { status }), ...(orderId === undefined ? {} : { order_id: orderId }), + ...(search ? { search } : {}), } } @@ -80,6 +83,9 @@ export async function fetchShipmentPage(filters: ShipmentFilters): Promise { - return fetchCount('/shipments', status === undefined ? {} : { status }) +export function fetchShipmentCount(status?: ShipmentStatus, search = ''): Promise { + return fetchCount('/shipments', { + ...(status === undefined ? {} : { status }), + ...(search ? { search } : {}), + }) } diff --git a/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx index 813cb8c..97e40e4 100644 --- a/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx +++ b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx @@ -150,6 +150,7 @@ export function ShipmentsTable({ activeTabId, onTabChange, pagination, + emptyMessage, onView, }: ShipmentsTableProps) { return ( @@ -161,7 +162,7 @@ export function ShipmentsTable({ tabs={tabs} activeTabId={activeTabId} onTabChange={onTabChange} - emptyMessage={table.empty} + emptyMessage={emptyMessage} pagination={pagination} paginationLabels={{ previousLabel: shipmentsCopy.pagination.previous, diff --git a/src/features/shipments/components/ShipmentsTable/ShipmentsTable.types.ts b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.types.ts index 4a62a82..b778005 100644 --- a/src/features/shipments/components/ShipmentsTable/ShipmentsTable.types.ts +++ b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.types.ts @@ -8,6 +8,12 @@ export interface ShipmentsTableProps { activeTabId: string onTabChange: (tabId: string) => void pagination: DataTablePagination + /** + * Qué decir con la tabla vacía. Lo elige la pantalla porque depende de si se + * buscó algo: una pestaña sin envíos y una búsqueda sin resultados no son lo + * mismo para quien está mirando. + */ + emptyMessage: string /** Abre la orden del envío: su detalle vive ahí, no en una pantalla propia. */ onView: (shipment: ShipmentSummary) => void } diff --git a/src/features/shipments/content.ts b/src/features/shipments/content.ts index bcac3e5..e09b0c9 100644 --- a/src/features/shipments/content.ts +++ b/src/features/shipments/content.ts @@ -13,6 +13,9 @@ export const shipmentsCopy = { title: 'Envíos', subtitle: 'Todos los envíos de la empresa y en qué anda cada uno.', error: 'No pudimos cargar los envíos.', + searchLabel: 'Buscar', + /** Los dos códigos que el operador tiene en la mano cuando lo llaman. */ + searchPlaceholder: 'Seguimiento u orden del canal', }, tabs: { all: 'Todos', @@ -51,6 +54,8 @@ export const shipmentsCopy = { table: { label: 'Envíos de la empresa', empty: 'No hay envíos para este filtro.', + /** Buscar y no encontrar no es lo mismo que una pestaña vacía. */ + emptySearch: 'Ningún envío coincide con la búsqueda.', /** El detalle del envío vive en la orden: no hay pantalla propia. */ view: 'Ver la orden', /** El ojito se repite en cada fila: el nombre accesible dice cuál es. */ diff --git a/src/features/shipments/hooks/useShipments.ts b/src/features/shipments/hooks/useShipments.ts index 8dcbab9..846eced 100644 --- a/src/features/shipments/hooks/useShipments.ts +++ b/src/features/shipments/hooks/useShipments.ts @@ -46,11 +46,11 @@ export function useShipmentPage(filters: ShipmentFilters) { * Que uno falle no voltea la pantalla: la tabla se ve igual y esa pestaña queda * sin número. */ -export function useShipmentCounts() { +export function useShipmentCounts(search = '') { return useQueries({ queries: SHIPMENT_TABS.map(({ status }) => ({ - queryKey: shipmentKeys.count(status), - queryFn: () => fetchShipmentCount(status), + queryKey: shipmentKeys.count(status, search), + queryFn: () => fetchShipmentCount(status, search), })), combine: (results) => results.map((result) => result.data), }) diff --git a/src/features/shipments/pages/ShipmentsPage.test.tsx b/src/features/shipments/pages/ShipmentsPage.test.tsx index 12e32c0..3a50eaf 100644 --- a/src/features/shipments/pages/ShipmentsPage.test.tsx +++ b/src/features/shipments/pages/ShipmentsPage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, within } from '@testing-library/react' +import { act, fireEvent, screen, within } from '@testing-library/react' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -47,6 +47,21 @@ function mockPage(rows: ShipmentSummary[], overrides: Partial = {} } as never) } +// Lo tipeado viaja con un debounce de 300 ms (`useDebouncedValue`): hay que +// dejarlo vencer para afirmar sobre lo que se le pidió a la API. +const SETTLE_MS = 400 + +function settle() { + return act(() => new Promise((resolve) => setTimeout(resolve, SETTLE_MS))) +} + +/** Los filtros del último pedido del listado. */ +function lastFilters() { + const calls = vi.mocked(useShipmentPage).mock.calls + + return calls[calls.length - 1][0] +} + function renderPage() { renderWithTheme( @@ -148,6 +163,72 @@ describe('ShipmentsPage', () => { expect(screen.getByText('Mostrando 0 a 0 de 0 envíos')).toBeInTheDocument() }) + // El buscador del listado (TESIS-164). + describe('search', () => { + function type(term: string) { + fireEvent.change(screen.getByRole('textbox', { name: 'Buscar' }), { + target: { value: term }, + }) + + return settle() + } + + it('sends the typed term to the API', async () => { + renderPage() + await type('AND-9920') + + expect(lastFilters().search).toBe('AND-9920') + }) + + // Sin el debounce cada tecla es un request: escribir el seguimiento entero + // son dieciséis, de los que quince se descartan. + it('does not send anything until the typing settles', () => { + renderPage() + fireEvent.change(screen.getByRole('textbox', { name: 'Buscar' }), { + target: { value: 'AND' }, + }) + + expect(lastFilters().search).toBe('') + }) + + // Buscar parado en la página 3 devolvía una página vacía: el resultado de + // la búsqueda entra en una sola. + it('goes back to the first page when the term changes', async () => { + mockPage([DISPATCHED, PENDING], { total: 45 }) + renderPage() + fireEvent.click(screen.getByRole('button', { name: 'Página siguiente' })) + expect(lastFilters().page).toBe(2) + + await type('AND') + + expect(lastFilters().page).toBe(1) + }) + + // Las pestañas tienen que contar lo mismo que muestra la tabla. + it('counts the tabs with the term applied', async () => { + renderPage() + await type('AND') + + expect(vi.mocked(useShipmentCounts)).toHaveBeenLastCalledWith('AND') + }) + + // Buscar y no encontrar no es lo mismo que una pestaña vacía. + it('says nothing matched instead of showing the empty tab message', async () => { + mockPage([], { total: 0 }) + renderPage() + await type('nada') + + expect(screen.getByText('Ningún envío coincide con la búsqueda.')).toBeInTheDocument() + }) + + it('keeps the empty tab message when nobody searched', () => { + mockPage([], { total: 0 }) + renderPage() + + expect(screen.getByText('No hay envíos para este filtro.')).toBeInTheDocument() + }) + }) + it('offers to retry when the listing fails', () => { const refetch = vi.fn() vi.mocked(useShipmentPage).mockReturnValue({ diff --git a/src/features/shipments/pages/ShipmentsPage.tsx b/src/features/shipments/pages/ShipmentsPage.tsx index 404bb1f..8008157 100644 --- a/src/features/shipments/pages/ShipmentsPage.tsx +++ b/src/features/shipments/pages/ShipmentsPage.tsx @@ -1,8 +1,10 @@ -import { Box, Stack, Typography } from '@mui/material' +import SearchIcon from '@mui/icons-material/Search' +import { Box, InputAdornment, Stack, TextField, Typography } from '@mui/material' import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components' import type { DataTableTab } from 'shared/components' +import { useDebouncedValue } from 'shared/hooks/useDebouncedValue' import { ShipmentsTable } from '../components/ShipmentsTable' import { formatCount, shipmentsCopy } from '../content' @@ -10,7 +12,7 @@ import { SHIPMENT_TABS, useShipmentCounts, useShipmentPage } from '../hooks/useS import type { ShipmentTabId } from '../hooks/useShipments' import type { ShipmentSummary } from '../types' -const { page: pageCopy, tabs: tabCopy, pagination } = shipmentsCopy +const { page: pageCopy, tabs: tabCopy, table, pagination } = shipmentsCopy const PER_PAGE = 20 @@ -42,18 +44,36 @@ export function ShipmentsPage() { const navigate = useNavigate() const [tabId, setTabId] = useState('all') const [page, setPage] = useState(1) + const [search, setSearch] = useState('') + + // Lo que se tipea actualiza el campo en el acto; lo que viaja a la API espera + // a que la persona deje de escribir. + const debouncedSearch = useDebouncedValue(search) const status = SHIPMENT_TABS.find((tab) => tab.id === tabId)?.status - const shipments = useShipmentPage({ page, perPage: PER_PAGE, status }) - const counts = useShipmentCounts() + const shipments = useShipmentPage({ + page, + perPage: PER_PAGE, + status, + search: debouncedSearch, + }) + // El término también va a los contadores: si no, las pestañas seguirían + // diciendo cuántos envíos tiene la empresa mientras la tabla muestra tres. + const counts = useShipmentCounts(debouncedSearch) - // Cambiar de pestaña vuelve a la primera página: quedarse en la 7 de un - // filtro que ahora tiene 2 páginas deja la tabla vacía sin motivo visible. + // Cambiar de pestaña o de búsqueda vuelve a la primera página: quedarse en la + // 7 de un filtro que ahora tiene 2 páginas deja la tabla vacía sin motivo + // visible. function changeTab(nextTabId: string) { setTabId(nextTabId as ShipmentTabId) setPage(1) } + function changeSearch(value: string) { + setSearch(value) + setPage(1) + } + const tabs: DataTableTab[] = SHIPMENT_TABS.map(({ id }, index) => ({ id, label: TAB_LABELS[id], @@ -85,14 +105,41 @@ export function ShipmentsPage() { return ( - - - {pageCopy.title} - - - {pageCopy.subtitle} - - + {/* `useFlexGap`: sin él `spacing` separa con `margin-left` y pisa el + `ml: 'auto'` que manda el buscador a la derecha (TESIS-132). */} + + + + {pageCopy.title} + + + {pageCopy.subtitle} + + + + changeSearch(event.target.value)} + label={pageCopy.searchLabel} + placeholder={pageCopy.searchPlaceholder} + sx={{ ml: { md: 'auto' }, width: { xs: '100%', sm: 300 }, flexShrink: 0 }} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + [...shipmentKeys.all, '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: ShipmentStatus | undefined) => - [...shipmentKeys.counts(), status ?? 'all'] as const, + // El término entra en la clave del contador igual que en la del listado: si + // no, las pestañas seguirían contando la empresa entera mientras la tabla + // muestra lo buscado. + count: (status: ShipmentStatus | undefined, search: string) => + [...shipmentKeys.counts(), status ?? 'all', search] as const, } diff --git a/src/features/shipments/types.ts b/src/features/shipments/types.ts index c04cad9..8be5031 100644 --- a/src/features/shipments/types.ts +++ b/src/features/shipments/types.ts @@ -43,4 +43,6 @@ export interface ShipmentFilters { status?: ShipmentStatus /** Filtra los envíos de una orden concreta. */ orderId?: number + /** Número de seguimiento o id del canal. Vacío es «sin buscar». */ + search?: string }