Skip to content
Merged
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
16 changes: 8 additions & 8 deletions docs/guidelines/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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é:**

Expand Down
41 changes: 41 additions & 0 deletions src/features/orders/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -42,6 +44,45 @@ const SHIPMENT = {
],
}

describe('fetchCatalogProducts', () => {
function capture() {
const sent: { params?: Record<string, unknown> }[] = []
vi.spyOn(client, 'get').mockImplementation((_url: string, config?: unknown) => {
sent.push(config as { params?: Record<string, unknown> })
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([]))
Expand Down
29 changes: 18 additions & 11 deletions src/features/orders/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CatalogProduct[]> {
/**
* 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<CatalogProduct[]> {
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) => ({
Expand Down
4 changes: 4 additions & 0 deletions src/features/orders/components/NewLineToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -37,6 +39,7 @@ export function NewLineToolbar({
warehouseId,
onWarehouseChange,
onAdd,
onSearchChange,
disabled,
}: NewLineToolbarProps) {
if (disabled) return null
Expand Down Expand Up @@ -74,6 +77,7 @@ export function NewLineToolbar({
products={products}
loading={productsLoading}
addedIds={addedIds}
onSearchChange={onSearchChange}
onAdd={onAdd}
/>
)}
Expand Down
14 changes: 12 additions & 2 deletions src/features/orders/components/OrderEditForm/OrderEditForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -228,12 +233,17 @@ export function OrderEditForm({
toolbar={
<NewLineToolbar
products={catalog.data ?? []}
productsLoading={catalog.isPending}
// `isFetching` además de `isPending`, igual que el paso 1: con
// `keepPreviousData`, una búsqueda que no trajo nada deja la
// lista vacía, y sin esto la pantalla diría «Ningún producto
// coincide.» mientras la búsqueda nueva todavía viaja.
productsLoading={catalog.isPending || catalog.isFetching}
addedIds={new Set(lines.map((line) => line.productId))}
warehouses={warehouses.data ?? []}
warehouseId={newLineWarehouse}
onWarehouseChange={setNewLineWarehouse}
onAdd={addLine}
onSearchChange={setProductSearch}
disabled={readOnly}
/>
}
Expand Down
35 changes: 30 additions & 5 deletions src/features/orders/components/ProductPicker/ProductPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<CatalogProduct | null>(null)
const [quantity, setQuantity] = useState(INITIAL_QUANTITY)
const [unitPrice, setUnitPrice] = useState(INITIAL_PRICE)
Expand All @@ -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)
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>
/**
* 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
}
2 changes: 1 addition & 1 deletion src/features/orders/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading
Loading