diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md
index 991dc8f..99a49a5 100644
--- a/docs/guidelines/architecture.md
+++ b/docs/guidelines/architecture.md
@@ -241,7 +241,7 @@ interface PaginationParams {
| `ProgressIndicator` | Ver `ProgressIndicator.types.ts` | Barra lineal con tono semántico. `size` thin/medium/large, `layout` stacked/inline, `indeterminate` para progreso desconocido. El ancho sale del porcentaje |
| `StepsProgress` | `total`, `completed`, `tone?`, `label?`, `caption?` | Progreso por etapas discretas, para procesos con pasos nombrados |
| `ProgressSkeleton` | `label?`, `avatar?`, `lines?` | Placeholder de carga con la silueta del contenido que reemplaza |
-| `TopNavBar` | Ver `TopNavBar.types.ts` | Shell de navegación global (brand + búsqueda + acciones + usuario). Presentacional — sin datos, sin `uiStore`; único acople: el `Link` de Router (brand/engranaje). Fixed/z-index intrínsecos vía `MuiAppBar` en el tema. Cableado real en `app/layout/Header.tsx`. |
+| `TopNavBar` | Ver `TopNavBar.types.ts` | Shell de navegación global (brand + acciones + usuario). El buscador se sacó en TESIS-163: no tenía `onSearch`, era un input decorativo. La campanita entrega el botón que la disparó, para que quien la monta ancle su panel ahí, y lo que cuelgue va como `children`. Presentacional — sin datos, sin `uiStore`; único acople: el `Link` de Router (brand/engranaje). Fixed/z-index intrínsecos vía `MuiAppBar` en el tema. Cableado real en `app/layout/Header.tsx`. |
| `StatusFeed` | Ver `StatusFeed.types.ts` | Bitácora de eventos de alta densidad: barra fina como eje temporal, título y metadato en monoespaciada. `current` marca la entrada vigente; el resto se atenúa. Renderiza `
` porque el orden es información |
| `Logo` | `brand`, `tagline` | Lockup de marca (isotipo + wordmark). Las reglas del manual viajan con el componente: área de respeto como padding propio y mínimo de 140px como `minWidth` |
| `LogoMark` | `size?: number` | Isotipo suelto, inline y con `currentColor` para que herede el color del contenedor |
@@ -308,15 +308,17 @@ features/[nombre]/
**Features existentes:**
-| Feature | Contenido |
-| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `dashboard` | Panel de operación y pantalla de entrada de la app (`/`, S03). Salud de los nodos de integración (`useInfraHealth`, `IntegrationNodeList`), alertas de inventario, carga por depósito y órdenes recientes (`useInventoryAlerts`, `WarehouseLoadCard`, `useRecentOrders`, `RecentOrdersTable`) |
-| `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks |
-| `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` |
-| `warehouses` | Depósitos de la empresa (`/warehouses`, RF-03): listado, alta, edición y baja sobre `/api/v1/warehouses`. Sin maqueta: tabla, modal y confirmación del DS. Sus mutaciones invalidan todas las queries, porque los depósitos llenan selects de otras features cuyas claves esta no puede importar |
-| `failed-events` | Cola de eventos fallidos (`/failed-events`, RF-16): lo que el motor de reintentos todavía intenta, lo agotado y lo resuelto, con reintento y descarte manuales. Las acciones de cada fila salen de `utils/actions.ts`, espejo de los estados que la API acepta |
-| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío, desde el que se despacha un envío que quedó pendiente (`/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) sobre `GET /reports/overview`: facturación, unidades despachadas, curva por día o semana y entregas por operador. Cumplimiento de plazo y anomalías llegan en `null` (el modelo no los tiene) y se muestran sin dato, diciendo por qué |
+| Feature | Contenido |
+| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `dashboard` | Panel de operación (`/dashboard`, S03). KPIs de órdenes pendientes, envíos activos, **unidades en stock** y alertas de inventario; abajo, las órdenes recientes y la columna con los **últimos envíos** y la carga por depósito. En TESIS-163 se fueron el KPI de «Salud del sistema» y la tarjeta de Integraciones —con ellos `useInfraHealth`, `IntegrationNodeList` y `NodeStatusIcon`—, y los reemplazos salen de datos que la pantalla ya pedía: las unidades guardadas que alimentan la carga por depósito, y `/shipments`. La barra de carga mide **ocupación real** en los depósitos que declararon capacidad (TESIS-162) y sigue comparando entre sí los que no |
+| `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` |
+| `warehouses` | Depósitos de la empresa (`/warehouses`, RF-03): listado, alta, edición y baja sobre `/api/v1/warehouses`. Sin maqueta: tabla, modal y confirmación del DS. Sus mutaciones invalidan todas las queries, porque los depósitos llenan selects de otras features cuyas claves esta no puede importar |
+| `failed-events` | Cola de eventos fallidos (`/failed-events`, RF-16): lo que el motor de reintentos todavía intenta, lo agotado y lo resuelto, con reintento y descarte manuales. Las acciones de cada fila salen de `utils/actions.ts`, espejo de los estados que la API acepta |
+| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío, desde el que se despacha un envío que quedó pendiente (`/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, **cómo la recibe el cliente** y destino; `/orders/new/carrier`: cotización y confirmación). Con **retiro en el local** (TESIS-162) el paso 3 no cotiza ni despacha, y el detalle y el listado lo dicen: una orden que no lleva envío deja de verse igual que una a la que le falta |
+| `reports` | Reportes — analítica de la operación (`/reports`, S14) sobre `GET /reports/overview`: facturación, unidades despachadas, curva por día o semana y entregas por operador. Cumplimiento de plazo y anomalías llegan en `null` (el modelo no los tiene) y se muestran sin dato, diciendo por qué |
+| `shipments` | Listado de envíos (`/shipments`, TESIS-163): pestañas por estado del ciclo, paginado sobre `GET /api/v1/shipments`, que existía desde TESIS-113 y sólo alimentaba el KPI del panel. No hay pantalla de detalle del envío y no es un olvido: el ciclo de vida, la bitácora y la etiqueta viven en el detalle de su orden (S08), así que cada fila lleva ahí |
**`orders` — piezas y por qué:**
diff --git a/src/app/layout/Header.tsx b/src/app/layout/Header.tsx
index bdcacb5..66f4627 100644
--- a/src/app/layout/Header.tsx
+++ b/src/app/layout/Header.tsx
@@ -1,8 +1,14 @@
-import { TopNavBar } from 'shared/components'
+import { useState } from 'react'
+import { ActivityPanel, TopNavBar } from 'shared/components'
import type { TopNavUser } from 'shared/components'
import { useAuthStore, useTenantName, useUiStore } from 'shared/store'
import { useThemeMode } from 'shared/store/uiStore'
+// Las rutas de destino del panel se declaran acá y no se importan del router:
+// `shared/components` no puede depender de `app/`. El de eventos fallidos
+// todavía no existe (TESIS-147); sin él la fila se muestra y no enlaza.
+const ACTIVITY_PATHS = { order: (id: number) => `/orders/${id}` }
+
export function Header() {
// Selectores individuales (no el store completo): Header está en todas las
// pantallas vía TopNavBar, así que solo re-renderiza cuando cambia una de
@@ -13,6 +19,8 @@ export function Header() {
const sessionUser = useAuthStore((state) => state.user)
const logout = useAuthStore((state) => state.logout)
const organization = useTenantName()
+ // El botón que abrió el panel: el Popover se ancla a él, y `null` lo cierra.
+ const [bell, setBell] = useState(null)
// `sessionUser` es null hasta que `GET /me` contesta, también al recargar la
// página con sesión abierta. El menú de cuenta cae a su nombre genérico
@@ -30,6 +38,9 @@ export function Header() {
onToggleTheme={toggleTheme}
user={user}
onLogout={logout}
- />
+ onNotificationsClick={setBell}
+ >
+ setBell(null)} paths={ACTIVITY_PATHS} />
+
)
}
diff --git a/src/app/router/routes.test.tsx b/src/app/router/routes.test.tsx
index 93c7b2b..00f8885 100644
--- a/src/app/router/routes.test.tsx
+++ b/src/app/router/routes.test.tsx
@@ -15,6 +15,7 @@ describe('navRoutes', () => {
expect(paths).toEqual([
'/',
'/orders',
+ '/shipments',
'/inventory',
'/warehouses',
'/failed-events',
diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx
index 6223c5e..79531b7 100644
--- a/src/app/router/routes.tsx
+++ b/src/app/router/routes.tsx
@@ -2,6 +2,7 @@ import BarChartOutlinedIcon from '@mui/icons-material/BarChartOutlined'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import InsightsOutlinedIcon from '@mui/icons-material/InsightsOutlined'
import Inventory2Icon from '@mui/icons-material/Inventory2'
+import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined'
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined'
import { lazy } from 'react'
@@ -36,6 +37,9 @@ const InventoryPage = lazy(() =>
const ProductDetailPage = lazy(() =>
import('features/inventory').then((m) => ({ default: m.ProductDetailPage })),
)
+const ShipmentsPage = lazy(() =>
+ import('features/shipments').then((m) => ({ default: m.ShipmentsPage })),
+)
const WarehousesPage = lazy(() =>
import('features/warehouses').then((m) => ({ default: m.WarehousesPage })),
)
@@ -117,6 +121,11 @@ export const appRoutes: AppRoute[] = [
element: ,
// Sin `nav`: se llega desde el listado, no desde el Sidebar.
},
+ {
+ path: '/shipments',
+ element: ,
+ nav: { label: 'Envíos', icon: },
+ },
{
path: '/inventory',
element: ,
diff --git a/src/features/dashboard/api.test.ts b/src/features/dashboard/api.test.ts
new file mode 100644
index 0000000..d221486
--- /dev/null
+++ b/src/features/dashboard/api.test.ts
@@ -0,0 +1,93 @@
+import type { AxiosResponse } from 'axios'
+import { client } from 'shared/api/client'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { fetchRecentShipments, fetchWarehouseLoads, RECENT_SHIPMENTS } from './api'
+
+function respond(data: unknown): AxiosResponse {
+ return { data, headers: {} } as AxiosResponse
+}
+
+/** Los query params con los que se llamó al endpoint. */
+function capture(body: unknown) {
+ const sent: Record[] = []
+ vi.spyOn(client, 'get').mockImplementation((_url: string, config?: unknown) => {
+ sent.push((config as { params?: Record })?.params ?? {})
+
+ return Promise.resolve(respond(body))
+ })
+
+ return sent
+}
+
+const SHIPMENT = {
+ id: 31,
+ order_id: 8829,
+ status: 'in_transit',
+ tracking_number: 'AND-9920-X8829-Z',
+ courier: { id: 4, service_id: 7, name: 'Andreani' },
+ created_at: '2026-08-24T12:14:00Z',
+}
+
+beforeEach(() => {
+ vi.restoreAllMocks()
+})
+
+describe('fetchRecentShipments', () => {
+ it('asks for one page of the size the panel shows', async () => {
+ const sent = capture({ data: [] })
+ await fetchRecentShipments()
+
+ expect(sent[0]).toMatchObject({ page: 1, per_page: RECENT_SHIPMENTS })
+ })
+
+ it('translates the shipment to the domain', async () => {
+ capture({ data: [SHIPMENT] })
+
+ expect(await fetchRecentShipments()).toEqual([
+ {
+ id: 31,
+ orderId: 8829,
+ status: 'in_transit',
+ trackingNumber: 'AND-9920-X8829-Z',
+ courier: 'Andreani',
+ createdAt: '2026-08-24T12:14:00Z',
+ },
+ ])
+ })
+
+ // El operador se asigna al confirmar el despacho (TESIS-47): hasta entonces
+ // la fila tiene que tolerar que no haya ninguno.
+ it('leaves the courier null for a shipment that has none', async () => {
+ capture({ data: [{ ...SHIPMENT, courier: null, tracking_number: null }] })
+ const [shipment] = await fetchRecentShipments()
+
+ expect(shipment).toMatchObject({ courier: null, trackingNumber: null })
+ })
+})
+
+describe('fetchWarehouseLoads', () => {
+ it('translates each warehouse to the domain', async () => {
+ capture({ data: [{ id: 1, name: 'CD Norte', stored_units: 200, capacity: 1000 }] })
+
+ expect(await fetchWarehouseLoads()).toEqual([
+ { id: 1, name: 'CD Norte', storedUnits: 200, capacity: 1000 },
+ ])
+ })
+
+ // Si el front se despliega antes que el backend que agrega el campo,
+ // `undefined` pasa el `!== null` de `shareOf` y la barra sale en «NaN %».
+ it('reads a missing capacity as one that was never declared', async () => {
+ capture({ data: [{ id: 1, name: 'CD Norte', stored_units: 200 }] })
+ const [warehouse] = await fetchWarehouseLoads()
+
+ expect(warehouse.capacity).toBeNull()
+ })
+
+ it('reads missing stored units as zero instead of NaN', async () => {
+ capture({ data: [{ id: 1, name: 'CD Norte', capacity: 1000 }] })
+ const [warehouse] = await fetchWarehouseLoads()
+
+ expect(warehouse.storedUnits).toBe(0)
+ })
+})
diff --git a/src/features/dashboard/api.ts b/src/features/dashboard/api.ts
index 4dda81f..18b6edf 100644
--- a/src/features/dashboard/api.ts
+++ b/src/features/dashboard/api.ts
@@ -1,7 +1,7 @@
import { client } from 'shared/api/client'
import { fetchCount } from 'shared/api/count'
-import type { RecentOrder, WarehouseLoad } from './types'
+import type { DispatchedShipment, RecentOrder, WarehouseLoad } from './types'
// Frontera con Rails de los KPIs de órdenes y envíos (TESIS-53). Único lugar de
// la feature que conoce los endpoints y el vocabulario de estados del backend.
@@ -77,6 +77,7 @@ interface ApiWarehouse {
id: number
name: string
stored_units: number
+ capacity: number | null
}
/**
@@ -92,7 +93,45 @@ export async function fetchWarehouseLoads(): Promise {
return data.data.map((warehouse) => ({
id: warehouse.id,
name: warehouse.name,
- storedUnits: warehouse.stored_units,
+ // `?? null` y no el valor crudo: si el front se despliega antes que el
+ // backend que agrega el campo, `undefined` pasa el `!== null` de
+ // `shareOf` y la barra sale en NaN %.
+ storedUnits: warehouse.stored_units ?? 0,
+ capacity: warehouse.capacity ?? null,
+ }))
+}
+
+/** Cuántos envíos muestra la tarjeta del panel. */
+export const RECENT_SHIPMENTS = 5
+
+interface ApiDispatchedShipment {
+ id: number
+ order_id: number
+ status: DispatchedShipment['status']
+ tracking_number: string | null
+ courier: { id: number; service_id: number; name: string } | null
+ created_at: string
+}
+
+/**
+ * Los últimos envíos de la empresa (`GET /api/v1/shipments?page=1&per_page=5`).
+ *
+ * Igual que las órdenes recientes: el backend los devuelve del más nuevo al más
+ * viejo, así que la primera página *es* «los últimos cinco» y no se reordena de
+ * este lado.
+ */
+export async function fetchRecentShipments(): Promise {
+ const { data } = await client.get<{ data: ApiDispatchedShipment[] }>('/shipments', {
+ params: { page: 1, per_page: RECENT_SHIPMENTS },
+ })
+
+ return data.data.map((shipment) => ({
+ id: shipment.id,
+ orderId: shipment.order_id,
+ status: shipment.status,
+ trackingNumber: shipment.tracking_number,
+ courier: shipment.courier?.name ?? null,
+ createdAt: shipment.created_at,
}))
}
diff --git a/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.styles.ts b/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.styles.ts
deleted file mode 100644
index 288961e..0000000
--- a/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.styles.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { Box, Card, Typography } from '@mui/material'
-import { styled } from '@mui/material/styles'
-
-// Geometría de la fila, tomada de `S03-Panel`: tile de 32px con radio 8 y un
-// ícono de 18px adentro.
-const SERVICE_TILE = { size: 32, iconSize: 18 }
-
-// Filas fantasma del estado de carga. Ids fijos y no índices: `react/no-array-index-key`.
-export const SKELETON_ROWS = ['first', 'second', 'third']
-
-export const SKELETON_TILE = SERVICE_TILE.size
-
-// El Card del tema ya trae borde/sombra/radio — acá solo layout.
-export const ListCard = styled(Card)(({ theme }) => ({
- padding: theme.spacing(2),
- display: 'flex',
- flexDirection: 'column',
- gap: theme.spacing(2),
- height: '100%',
-}))
-
-// El ícono `hub` del diseño va al extremo derecho de la cabecera.
-export const HeaderRow = styled(Box)(({ theme }) => ({
- display: 'flex',
- alignItems: 'center',
- gap: theme.spacing(1),
- '& > svg': { marginLeft: 'auto', color: theme.vars.palette.text.secondary, display: 'block' },
-}))
-
-export const NodeRows = styled(Box)(({ theme }) => ({
- display: 'flex',
- flexDirection: 'column',
- gap: theme.spacing(2),
-}))
-
-export const NodeRow = styled(Box)(({ theme }) => ({
- display: 'flex',
- alignItems: 'center',
- gap: theme.spacing(1.5),
-}))
-
-// Tile de identidad del servicio. Superficie tonal del tema, no un color crudo.
-export const ServiceTile = styled(Box)(({ theme }) => ({
- width: SERVICE_TILE.size,
- height: SERVICE_TILE.size,
- flexShrink: 0,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: theme.shape.borderRadius,
- backgroundColor: theme.vars.palette.background.containerHighest,
- color: theme.vars.palette.text.secondary,
- '& svg': { fontSize: SERVICE_TILE.iconSize, display: 'block' },
-}))
-
-// `minWidth: 0` para que el nombre largo se recorte en vez de empujar el ícono
-// de estado fuera de la tarjeta.
-export const NodeIdentity = styled(Box)({
- display: 'flex',
- flexDirection: 'column',
- flexGrow: 1,
- minWidth: 0,
-})
-
-// El diseño pide el nombre en 14px semibold: el tamaño sale de `bodyMd` y el
-// peso de `labelMd`, así ningún número queda escrito acá.
-export const NodeName = styled(Typography)(({ theme }) => ({
- fontWeight: theme.typography.labelMd.fontWeight,
- // El nombre sí se recorta: es reconocible por el prefijo y no puede robarle
- // alto a la fila.
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
-}))
-
-interface SyncLineProps {
- degraded: boolean
-}
-
-const TRANSIENT_PROPS = new Set(['degraded'])
-
-// Línea de sincronización: tamaño de `labelSm` con la familia monoespaciada de
-// `dataMono`, como en el diseño. En rojo cuando la sync quedó atrasada.
-export const SyncLine = styled(Typography, {
- shouldForwardProp: (prop) => !TRANSIENT_PROPS.has(prop as string),
-})(({ theme, degraded }) => ({
- fontFamily: theme.typography.dataMono.fontFamily,
- color: degraded ? theme.vars.palette.error.main : theme.vars.palette.text.secondary,
- // A diferencia del nombre, esta línea NO se recorta: el tiempo transcurrido
- // vive al final de la frase, y truncarlo sería perder justamente el dato que
- // la fila existe para mostrar. En una columna angosta envuelve a dos renglones.
-}))
diff --git a/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.tsx b/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.tsx
deleted file mode 100644
index b0e59b1..0000000
--- a/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import HubOutlinedIcon from '@mui/icons-material/HubOutlined'
-import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
-import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
-import { Skeleton, Typography } from '@mui/material'
-import type { ReactNode } from 'react'
-import type { ServiceType } from 'shared/api'
-import { formatRelativeTime } from 'shared/utils'
-
-import { dashboardCopy } from '../../content'
-import type { InfraNode } from '../../hooks/useInfraHealth'
-import { NodeStatusIcon } from '../NodeStatusIcon'
-
-import {
- HeaderRow,
- ListCard,
- NodeIdentity,
- NodeName,
- NodeRow,
- NodeRows,
- SKELETON_ROWS,
- SKELETON_TILE,
- ServiceTile,
- SyncLine,
-} from './IntegrationNodeList.styles'
-import type { IntegrationNodeListProps } from './IntegrationNodeList.types'
-
-const nodesCopy = dashboardCopy.infra.nodes
-
-// El diseño distingue los nodos por el ícono del tile. Acá el criterio es el
-// tipo de servicio, que es el único eje que expone la API.
-const SERVICE_ICON: Record = {
- ecommerce: ,
- courier: ,
-}
-
-// Frase de estado de la fila. Sin marca de sync no se inventa un tiempo: se
-// dice que el dato no está.
-function syncLabel(node: InfraNode): string {
- if (!node.lastSyncedAt) return nodesCopy.sync.unknown
-
- const elapsed = formatRelativeTime(node.lastSyncedAt)
- if (elapsed === null) return nodesCopy.sync.unknown
-
- return node.status === 'stale' ? nodesCopy.sync.stale(elapsed) : nodesCopy.sync.online(elapsed)
-}
-
-// Widget "Integraciones" de `S03-Panel`: por cada nodo activo, su identidad, el
-// tiempo desde la última sincronización exitosa y el estado resultante.
-export function IntegrationNodeList({
- nodes,
- reportingNodes,
- onlineNodes,
- loading = false,
-}: IntegrationNodeListProps) {
- // Mientras ningún nodo reporte sync, el contador "x/y sincronizados" no
- // significa nada: se muestra cuántas integraciones activas hay y se aclara
- // que la marca de sync no está llegando.
- const subtitle =
- reportingNodes === 0
- ? nodesCopy.subtitleNoReports(nodes.length)
- : nodesCopy.subtitleSynced(onlineNodes, reportingNodes)
-
- const rows = loading
- ? SKELETON_ROWS.map((id) => (
-
-
-
-
- ))
- : nodes.map((node) => (
-
-
- {SERVICE_ICON[node.type]}
-
-
- {node.name}
-
- {syncLabel(node)}
-
-
-
-
- ))
-
- return (
-
-
- {nodesCopy.title}
-
-
-
-
- {loading ? '' : subtitle}
-
-
- {!loading && nodes.length === 0 ? (
-
- {nodesCopy.empty}
-
- ) : (
- {rows}
- )}
-
- )
-}
diff --git a/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.types.ts b/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.types.ts
deleted file mode 100644
index 812ab81..0000000
--- a/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.types.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { InfraNode } from '../../hooks/useInfraHealth'
-
-export interface IntegrationNodeListProps {
- /** Nodos ya filtrados (configurados y activos) y derivados por `useInfraHealth`. */
- nodes: InfraNode[]
- /** Nodos que traen marca de sync — denominador del contador de la cabecera. */
- reportingNodes: number
- onlineNodes: number
- /** Muestra filas fantasma mientras llega la respuesta. */
- loading?: boolean
-}
diff --git a/src/features/dashboard/components/IntegrationNodeList/index.ts b/src/features/dashboard/components/IntegrationNodeList/index.ts
deleted file mode 100644
index f869d00..0000000
--- a/src/features/dashboard/components/IntegrationNodeList/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { IntegrationNodeList } from './IntegrationNodeList'
-export type { IntegrationNodeListProps } from './IntegrationNodeList.types'
diff --git a/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.styles.ts b/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.styles.ts
deleted file mode 100644
index 8a69fdd..0000000
--- a/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.styles.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Box } from '@mui/material'
-import { styled } from '@mui/material/styles'
-import type { StatusVariant } from 'shared/components'
-
-import type { NodeSyncStatus } from '../../types'
-
-// Tono por estado. `S03-Panel` pinta el nodo sano con `--ok` y el caído con
-// `--err`; el diseño no contempla un tercer estado porque asume que todo nodo
-// reporta, así que `unknown` va en neutro — apagado, no alarmado.
-export const STATUS_TONE: Record = {
- online: 'success',
- stale: 'error',
- unknown: 'neutral',
-}
-
-// 16px es el tamaño del ícono de estado en el diseño.
-const ICON_SIZE = 16
-
-interface StatusRootProps {
- tone: StatusVariant
-}
-
-const TRANSIENT_PROPS = new Set(['tone'])
-
-export const StatusRoot = styled(Box, {
- shouldForwardProp: (prop) => !TRANSIENT_PROPS.has(prop as string),
-})(({ theme, tone }) => ({
- display: 'inline-flex',
- flexShrink: 0,
- color: theme.vars.palette[tone].main,
- '& svg': { display: 'block', fontSize: ICON_SIZE },
-}))
diff --git a/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.tsx b/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.tsx
deleted file mode 100644
index 72ba6b8..0000000
--- a/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import CheckCircleIcon from '@mui/icons-material/CheckCircle'
-import ErrorIcon from '@mui/icons-material/Error'
-import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'
-import type { ReactNode } from 'react'
-
-import type { NodeSyncStatus } from '../../types'
-
-import { STATUS_TONE, StatusRoot } from './NodeStatusIcon.styles'
-import type { NodeStatusIconProps } from './NodeStatusIcon.types'
-
-// Los dos primeros son los del diseño (`check_circle` / `error`). El tercero
-// cubre el nodo que no reporta: un círculo tachado lee como "sin señal" y no
-// como "falla".
-const STATUS_ICON: Record = {
- online: ,
- stale: ,
- unknown: ,
-}
-
-// Indicador de estado del nodo, en la columna derecha de la fila.
-export function NodeStatusIcon({ status, label }: NodeStatusIconProps) {
- return (
-
- {STATUS_ICON[status]}
-
- )
-}
diff --git a/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.types.ts b/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.types.ts
deleted file mode 100644
index 8c5c17d..0000000
--- a/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.types.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { NodeSyncStatus } from '../../types'
-
-export interface NodeStatusIconProps {
- /** Estado de frescura del nodo, ya derivado por `useInfraHealth`. */
- status: NodeSyncStatus
- /**
- * Texto del estado. Obligatorio a propósito: el ícono nunca comunica solo con
- * color, y además es lo único que lee un lector de pantalla en esta columna.
- */
- label: string
-}
diff --git a/src/features/dashboard/components/NodeStatusIcon/index.ts b/src/features/dashboard/components/NodeStatusIcon/index.ts
deleted file mode 100644
index e2a9f31..0000000
--- a/src/features/dashboard/components/NodeStatusIcon/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { NodeStatusIcon } from './NodeStatusIcon'
-export type { NodeStatusIconProps } from './NodeStatusIcon.types'
diff --git a/src/features/dashboard/components/RecentOrdersTable/RecentOrdersTable.tsx b/src/features/dashboard/components/RecentOrdersTable/RecentOrdersTable.tsx
index 4fbd1a6..a35dc4b 100644
--- a/src/features/dashboard/components/RecentOrdersTable/RecentOrdersTable.tsx
+++ b/src/features/dashboard/components/RecentOrdersTable/RecentOrdersTable.tsx
@@ -3,11 +3,11 @@ import { Button, Link, Typography } from '@mui/material'
import { Link as RouterLink } from 'react-router-dom'
import { DataTable, StackedCell, StatusBadge } from 'shared/components'
import type { DataTableColumn } from 'shared/components'
+import { formatMoney } from 'shared/utils'
import { dashboardCopy } from '../../content'
import type { RecentOrder } from '../../types'
import {
- formatMoney,
formatOrderId,
formatShortDate,
formatTime,
diff --git a/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.styles.ts b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.styles.ts
new file mode 100644
index 0000000..8ab4369
--- /dev/null
+++ b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.styles.ts
@@ -0,0 +1,93 @@
+import { Box, Card, Typography } from '@mui/material'
+import { styled } from '@mui/material/styles'
+import { Link as RouterLink } from 'react-router-dom'
+
+// Misma geometría que tenía la tarjeta de Integraciones, a la que reemplaza
+// (TESIS-163): la columna de la derecha del panel no cambia de forma.
+const SERVICE_TILE = { size: 32, iconSize: 18 }
+
+// Filas fantasma del estado de carga. Ids fijos y no índices: `react/no-array-index-key`.
+export const SKELETON_ROWS = ['first', 'second', 'third']
+
+export const SKELETON_TILE = SERVICE_TILE.size
+
+// El Card del tema ya trae borde/sombra/radio — acá solo layout.
+export const ListCard = styled(Card)(({ theme }) => ({
+ padding: theme.spacing(2),
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(2),
+ height: '100%',
+}))
+
+export const HeaderRow = styled(Box)(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ gap: theme.spacing(1),
+ '& > svg': { marginLeft: 'auto', color: theme.vars.palette.text.secondary, display: 'block' },
+}))
+
+export const ShipmentRows = styled(Box)(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(2),
+}))
+
+export const ShipmentRow = styled(Box)(({ theme }) => ({
+ display: 'flex',
+ alignItems: 'center',
+ gap: theme.spacing(1.5),
+}))
+
+// La fila entera lleva a la orden, que es donde vive el detalle del envío. Es
+// un `Link` envolviendo y no un `component` en la fila: así el skeleton usa la
+// misma fila sin arrastrar un destino que todavía no existe.
+export const RowLink = styled(RouterLink)(({ theme }) => ({
+ textDecoration: 'none',
+ color: 'inherit',
+ borderRadius: theme.shape.borderRadius,
+ '&:hover': { backgroundColor: theme.alpha(theme.vars.palette.text.primary, 0.04) },
+ '&:focus-visible': {
+ outline: `2px solid ${theme.vars.palette.primary.main}`,
+ outlineOffset: 2,
+ },
+}))
+
+export const ShipmentTile = styled(Box)(({ theme }) => ({
+ width: SERVICE_TILE.size,
+ height: SERVICE_TILE.size,
+ flexShrink: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: theme.shape.borderRadius,
+ backgroundColor: theme.vars.palette.background.containerHighest,
+ color: theme.vars.palette.text.secondary,
+ '& svg': { fontSize: SERVICE_TILE.iconSize, display: 'block' },
+}))
+
+// `minWidth: 0` para que el operador largo se recorte en vez de empujar el
+// estado fuera de la tarjeta.
+export const ShipmentIdentity = styled(Box)({
+ display: 'flex',
+ flexDirection: 'column',
+ flexGrow: 1,
+ minWidth: 0,
+})
+
+export const CourierName = styled(Typography)(({ theme }) => ({
+ fontWeight: theme.typography.labelMd.fontWeight,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+}))
+
+// El seguimiento va en monoespaciada: es un código que se compara carácter a
+// carácter contra el que muestra el courier.
+export const TrackingLine = styled(Typography)(({ theme }) => ({
+ fontFamily: theme.typography.dataMono.fontFamily,
+ color: theme.vars.palette.text.secondary,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+}))
diff --git a/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.tsx b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.tsx
new file mode 100644
index 0000000..d36d844
--- /dev/null
+++ b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.tsx
@@ -0,0 +1,103 @@
+import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
+import { Skeleton, Typography } from '@mui/material'
+import { StatusBadge } from 'shared/components'
+
+import { dashboardCopy } from '../../content'
+
+import {
+ CourierName,
+ RowLink,
+ HeaderRow,
+ ListCard,
+ ShipmentIdentity,
+ ShipmentRow,
+ ShipmentRows,
+ ShipmentTile,
+ SKELETON_ROWS,
+ SKELETON_TILE,
+ TrackingLine,
+} from './RecentShipmentsCard.styles'
+import type { RecentShipmentsCardProps } from './RecentShipmentsCard.types'
+
+const shipmentsCopy = dashboardCopy.shipments
+
+// El tono del estado, con el mismo vocabulario que el resto del producto. Mapa
+// explícito y no un `switch` con default: si el backend suma un estado, el
+// compilador marca este archivo en vez de pintarlo en gris sin avisar.
+const TONES = {
+ pending: 'neutral',
+ ready_to_ship: 'info',
+ in_transit: 'info',
+ delivered: 'success',
+} as const
+
+/**
+ * «Últimos envíos» del panel (TESIS-163).
+ *
+ * Reemplaza a la tarjeta de Integraciones, que mostraba la salud de los nodos:
+ * las conexiones las administra el equipo y no la empresa, y lo que al operador
+ * le sirve de esa columna es qué salió, con quién y en qué anda.
+ *
+ * Sale de `GET /shipments`, que existe paginado desde TESIS-113 y hasta ahora
+ * sólo alimentaba el KPI de envíos activos.
+ */
+export function RecentShipmentsCard({ shipments, loading = false }: RecentShipmentsCardProps) {
+ const rows = loading
+ ? SKELETON_ROWS.map((id) => (
+
+
+
+
+ ))
+ : shipments.map((shipment) => (
+ // La fila entera lleva a la orden: el detalle del envío vive ahí, no en
+ // una pantalla propia.
+
+
+
+
+
+
+ {/* El operador se asigna al confirmar el despacho (TESIS-47): hasta
+ entonces se dice que no hay, en vez de dejar la línea vacía. */}
+
+ {shipment.courier ?? shipmentsCopy.noCourier}
+
+
+ {shipment.trackingNumber ?? shipmentsCopy.noTracking}
+
+
+
+
+
+ ))
+
+ return (
+
+
+ {shipmentsCopy.title}
+
+
+
+
+ {loading ? '' : shipmentsCopy.subtitle(shipments.length)}
+
+
+ {!loading && shipments.length === 0 ? (
+
+ {shipmentsCopy.empty}
+
+ ) : (
+ {rows}
+ )}
+
+ )
+}
diff --git a/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.types.ts b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.types.ts
new file mode 100644
index 0000000..09dc68a
--- /dev/null
+++ b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.types.ts
@@ -0,0 +1,8 @@
+import type { DispatchedShipment } from '../../types'
+
+export interface RecentShipmentsCardProps {
+ /** Los últimos envíos, en el orden que los devuelve la API. */
+ shipments: DispatchedShipment[]
+ /** Muestra filas fantasma mientras llega la respuesta. */
+ loading?: boolean
+}
diff --git a/src/features/dashboard/components/RecentShipmentsCard/index.ts b/src/features/dashboard/components/RecentShipmentsCard/index.ts
new file mode 100644
index 0000000..da54a77
--- /dev/null
+++ b/src/features/dashboard/components/RecentShipmentsCard/index.ts
@@ -0,0 +1,2 @@
+export { RecentShipmentsCard } from './RecentShipmentsCard'
+export type { RecentShipmentsCardProps } from './RecentShipmentsCard.types'
diff --git a/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx b/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx
index 2017088..3a3e295 100644
--- a/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx
+++ b/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx
@@ -21,16 +21,16 @@ const copy = dashboardCopy.warehouses
/**
* Cuánto guarda cada depósito, ordenados de más a menos cargado.
*
- * El diseño dibuja acá un porcentaje de ocupación con tres tonos (azul, naranja,
- * rojo) según qué tan lleno está el depósito. Eso no se puede mostrar: la tabla
- * `warehouses` no tiene ninguna capacidad máxima contra la cual medir, y un
- * porcentaje inventado pondría un número sin significado en un panel cuyo
- * subtítulo promete datos en vivo.
+ * El diseño dibuja acá un porcentaje de ocupación. Desde TESIS-162 el depósito
+ * puede declarar su capacidad, así que el de los que la declararon es real: la
+ * barra mide cuánto de lo que entra está ocupado, y la fila lo dice.
*
- * Lo que sí es real es cuánto guarda cada uno, así que la barra compara los
- * depósitos entre sí: el 100% es el más cargado. Por eso todas las barras van
- * en el mismo tono —no codifican riesgo, codifican proporción— y el epígrafe
- * dice contra qué se mide, para que nadie lea ocupación donde no la hay.
+ * El de los que no la declararon sigue sin existir, y ahí la barra compara los
+ * depósitos entre sí —el 100 % es el más cargado—, que es la única comparación
+ * real que queda. Un porcentaje de ocupación sin techo sería inventado.
+ *
+ * Por eso todas las barras van en el mismo tono: no codifican riesgo, y lo que
+ * cada una mide lo dice su fila y su rótulo accesible.
*
* Presentacional: recibe los depósitos ya ordenados y con su proporción.
*/
@@ -55,7 +55,11 @@ export function WarehouseLoadCard({
) : (
<>
- {warehouses.length === 0 ? copy.empty : copy.caption(storedUnits)}
+ {storedUnits === undefined
+ ? copy.unavailable
+ : warehouses.length === 0
+ ? copy.empty
+ : copy.caption(storedUnits)}
@@ -63,17 +67,39 @@ export function WarehouseLoadCard({
{warehouse.name}
- {copy.units(warehouse.storedUnits)}
+
+ {warehouse.measuredAgainstCapacity
+ ? copy.occupancy(warehouse.share)
+ : copy.units(warehouse.storedUnits)}
+
))}
+
+ {warehouses.some((warehouse) => !warehouse.measuredAgainstCapacity) && (
+
+ {copy.relativeNote}
+
+ )}
>
)}
diff --git a/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.types.ts b/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.types.ts
index 2f58eb5..dfaf093 100644
--- a/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.types.ts
+++ b/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.types.ts
@@ -4,7 +4,8 @@ export interface WarehouseLoadCardProps {
/** Depósitos ya ordenados y con su proporción, derivados por `warehouseShares`. */
warehouses: WarehouseShare[]
/** Unidades guardadas entre todos: el epígrafe de la cabecera. */
- storedUnits: number
+ /** `undefined` si el listado no llegó: no es lo mismo que cero unidades. */
+ storedUnits: number | undefined
/** Muestra filas fantasma mientras llega la respuesta. */
loading?: boolean
}
diff --git a/src/features/dashboard/content.ts b/src/features/dashboard/content.ts
index 468e037..96ae566 100644
--- a/src/features/dashboard/content.ts
+++ b/src/features/dashboard/content.ts
@@ -2,14 +2,12 @@
// idea que `content.ts` de design-system: si mañana sumamos i18n, este módulo es
// el único punto a migrar a claves de traducción).
+import { formatInteger } from 'shared/utils'
+
// Lo que muestra una tarjeta cuando el dato no está: falló la consulta o no hay
// con qué calcularlo. Nunca un 0, que se leería como un dato real.
const UNKNOWN_VALUE = '—'
-// Separadores de miles del locale, para las unidades del widget de depósitos.
-// El componente del DS recibe el valor ya formateado.
-const UNITS_FORMAT = new Intl.NumberFormat('es-AR')
-
export const dashboardCopy = {
pageTitle: 'Panel de operación',
pageSubtitle: 'Datos en vivo de los centros de distribución.',
@@ -22,6 +20,17 @@ export const dashboardCopy = {
pendingOrders: {
label: 'Órdenes pendientes',
},
+ // Reemplaza al KPI de «Salud del sistema» (TESIS-163): la misma salud de
+ // las integraciones, dicha como un número sobre el que se puede actuar.
+ // Reemplaza al KPI de «Salud del sistema» (TESIS-163), que mostraba un
+ // porcentaje derivado de la frescura de los nodos. Las unidades guardadas
+ // son el inventario del que vive la operación, y salen del mismo dato que
+ // ya alimenta la carga por depósito: ni un request más.
+ storedUnits: {
+ label: 'Unidades en stock',
+ note: (warehouses: number) =>
+ warehouses === 1 ? 'en 1 depósito' : `repartidas en ${warehouses} depósitos`,
+ },
// Cuarta tarjeta de la fila del diseño. El chip y la nota son los del
// MetricCard de S03-Panel; el tono `error` es lo que le da el borde de
// acento que el diseño marca como `critical`.
@@ -32,8 +41,8 @@ export const dashboardCopy = {
// umbral no son lo mismo y se trabajan distinto, pero los dos son alerta.
note: (outOfStock: number, low: number) =>
outOfStock === 0
- ? `${UNITS_FORMAT.format(low)} por debajo del umbral`
- : `${UNITS_FORMAT.format(outOfStock)} sin stock · ${UNITS_FORMAT.format(low)} por debajo del umbral`,
+ ? `${formatInteger(low)} por debajo del umbral`
+ : `${formatInteger(outOfStock)} sin stock · ${formatInteger(low)} por debajo del umbral`,
// Sin productos en alerta la tarjeta no grita: el borde rojo y el chip
// «Crítico» afirmarían un problema que no existe.
calmNote: 'Sin productos en alerta de stock',
@@ -54,11 +63,23 @@ export const dashboardCopy = {
warehouses: {
title: 'Carga por depósito',
caption: (units: number) =>
- `${UNITS_FORMAT.format(units)} ${units === 1 ? 'unidad guardada' : 'unidades guardadas'} · la barra compara contra el depósito más cargado`,
- units: (units: number) => `${UNITS_FORMAT.format(units)} u`,
+ `${formatInteger(units)} ${units === 1 ? 'unidad guardada' : 'unidades guardadas'}`,
+ units: (units: number) => `${formatInteger(units)} u`,
+ // Con capacidad declarada (TESIS-162) se muestra la ocupación; sin ella, lo
+ // guardado a secas. El rótulo accesible dice contra qué se mide, porque las
+ // dos barras se ven igual y significan cosas distintas.
+ occupancy: (percentage: number) => `${Math.round(percentage)} % de su capacidad`,
barLabel: (name: string, units: number) =>
- `${name}: ${UNITS_FORMAT.format(units)} ${units === 1 ? 'unidad' : 'unidades'}`,
+ `${name}: ${formatInteger(units)} ${units === 1 ? 'unidad' : 'unidades'}`,
+ barLabelWithCapacity: (name: string, units: number, capacity: number) =>
+ `${name}: ${formatInteger(units)} de ${formatInteger(capacity)} unidades de capacidad`,
+ // Las dos barras se ven igual y miden cosas distintas: una la ocupación
+ // declarada y la otra la comparación contra el depósito más cargado. El
+ // rótulo accesible lo dice por fila; esto lo dice para quien mira.
+ relativeNote: 'Los depósitos sin capacidad declarada se comparan con el más cargado.',
empty: 'La empresa no tiene depósitos cargados.',
+ /** La lista vacía por un error no es la lista vacía de una empresa sin depósitos. */
+ unavailable: 'No pudimos cargar la carga por depósito.',
},
// Tabla de órdenes recientes del panel (TESIS-56). Vocabulario y orden de
// columnas de S03-Panel.
@@ -85,36 +106,27 @@ export const dashboardCopy = {
cancelled: 'Cancelada',
},
},
- infra: {
- health: {
- // Vocabulario del diseño (S03-Panel): "Salud del sistema", no "de infraestructura".
- label: 'Salud del sistema',
- unknownValue: UNKNOWN_VALUE,
- },
- nodes: {
- title: 'Integraciones',
- subtitleSynced: (online: number, reporting: number) =>
- `${online}/${reporting} ${reporting === 1 ? 'nodo sincronizado' : 'nodos sincronizados'}`,
- subtitleNoReports: (active: number) =>
- `${active} ${active === 1 ? 'integración activa' : 'integraciones activas'} · sin datos de sincronización`,
- empty: 'La empresa no tiene integraciones activas.',
- // Línea inferior de cada fila. El diseño la usa como frase de estado
- // ("Sincronizado hace 2 ms"), no como un timestamp suelto.
- sync: {
- online: (elapsed: string) => `Sincronizado ${elapsed}`,
- stale: (elapsed: string) => `Sin sincronizar desde ${elapsed}`,
- unknown: 'Sin datos de sincronización',
- },
- // Texto accesible del ícono de estado.
- status: {
- online: 'Sincronizado',
- stale: 'Sincronización atrasada',
- unknown: 'Sin datos de sincronización',
- },
- types: {
- ecommerce: 'E-commerce',
- courier: 'Courier',
- },
+ // La tarjeta «Últimos envíos» reemplaza a la de Integraciones (TESIS-163):
+ // las conexiones las administra el equipo, no la empresa, y lo que sí le
+ // sirve al operador es qué salió y con quién.
+ shipments: {
+ title: 'Últimos envíos',
+ subtitle: (count: number) => (count === 1 ? '1 envío reciente' : `${count} envíos recientes`),
+ empty: 'Todavía no se despachó ningún envío.',
+ noCourier: 'Sin operador asignado',
+ noTracking: 'Sin seguimiento',
+ /** "Orden #8829" — nombre accesible de la fila. */
+ order: (id: number) => `Orden #${id}`,
+ /** La fila entera es un enlace: el nombre accesible dice a dónde lleva. */
+ openOrder: (id: number) => `Ver la orden #${id}`,
+ // El destino se declara acá y no se importa del router: una feature no
+ // puede depender de `app/` (architecture.md §3.2).
+ orderPath: (id: number) => `/orders/${id}`,
+ status: {
+ pending: 'Pendiente',
+ ready_to_ship: 'Listo para despachar',
+ in_transit: 'En tránsito',
+ delivered: 'Entregado',
},
},
error: {
diff --git a/src/features/dashboard/hooks/useInfraHealth.ts b/src/features/dashboard/hooks/useInfraHealth.ts
deleted file mode 100644
index 4dbcad0..0000000
--- a/src/features/dashboard/hooks/useInfraHealth.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { useCallback, useMemo } from 'react'
-import type { IntegrationService, ServiceType } from 'shared/api'
-import type { StatTone } from 'shared/components'
-import { useIntegrations } from 'shared/hooks/useIntegrations'
-
-import type { NodeSyncStatus } from '../types'
-
-import { useNow } from './useNow'
-
-// Ventana de frescura: una sync más vieja que esto se considera fuera de los
-// parámetros normales de latencia y prende el LED en rojo.
-//
-// El valor es provisorio: la card no fija un umbral y el worker de sync
-// (TESIS-35) todavía no define su cadencia. Cuando exista, este número tiene que
-// pasar a ser ~2x el intervalo del worker.
-export const SYNC_STALE_THRESHOLD_MS = 15 * 60 * 1000
-
-// Cada cuánto avanza el reloj del widget. 30s alcanza: los tiempos se muestran
-// con granularidad de minuto.
-const CLOCK_INTERVAL_MS = 30 * 1000
-
-// Cortes de salud para el color del KPI. Por encima de `healthy` está todo bien;
-// entre medio hay nodos caídos pero el sistema opera; por debajo es incidente.
-const HEALTH_THRESHOLD = { healthy: 90, degraded: 50 }
-
-export interface InfraNode {
- serviceId: number
- name: string
- type: ServiceType
- status: NodeSyncStatus
- /** ISO de la última sync exitosa, o `null` si el backend todavía no la manda. */
- lastSyncedAt: string | null
-}
-
-export interface InfraHealth {
- /** Solo los nodos con integración configurada y activa, ordenados por nombre. */
- nodes: InfraNode[]
- activeNodes: number
- /** Nodos activos que sí traen una marca de sync válida (denominador del KPI). */
- reportingNodes: number
- onlineNodes: number
- /** Porcentaje 0-100, o `null` si ningún nodo reporta sync todavía. */
- healthPercentage: number | null
- healthTone: StatTone
- isLoading: boolean
- isError: boolean
- error: Error | null
- refetch: () => void
-}
-
-// `last_synced_at` puede no venir (contrato actual), venir `null` (nunca
-// sincronizó) o traer una fecha inválida. Los tres casos son `unknown`: no
-// sabemos el estado del nodo y no lo damos por sano.
-function resolveStatus(lastSyncedAt: string | null, now: number): NodeSyncStatus {
- if (!lastSyncedAt) return 'unknown'
-
- const syncedAt = new Date(lastSyncedAt).getTime()
- if (Number.isNaN(syncedAt)) return 'unknown'
-
- return now - syncedAt <= SYNC_STALE_THRESHOLD_MS ? 'online' : 'stale'
-}
-
-function toNode(integration: IntegrationService, now: number): InfraNode {
- return {
- serviceId: integration.serviceId,
- name: integration.name,
- type: integration.type,
- status: resolveStatus(integration.lastSyncedAt, now),
- lastSyncedAt: integration.lastSyncedAt,
- }
-}
-
-function resolveTone(healthPercentage: number | null): StatTone {
- if (healthPercentage === null) return 'neutral'
- if (healthPercentage >= HEALTH_THRESHOLD.healthy) return 'success'
- if (healthPercentage >= HEALTH_THRESHOLD.degraded) return 'warning'
- return 'error'
-}
-
-// Deriva los nodos y el KPI de salud de la lista que ya trajo React Query: es una
-// proyección memoizada sobre `data`, sin estado duplicado ni fetch extra. La
-// dependencia de `now` es lo que hace que un nodo pase a `stale` con el paso del
-// tiempo, sin necesidad de refetch.
-export function useInfraHealth(): InfraHealth {
- const { data, isLoading, isError, error, refetch } = useIntegrations()
- const now = useNow(CLOCK_INTERVAL_MS)
-
- // Envuelto: `refetch` de React Query recibe `RefetchOptions`, y cablearlo
- // directo a un `onClick` le pasaría el MouseEvent como opciones.
- const retry = useCallback(() => {
- void refetch()
- }, [refetch])
-
- const nodes = useMemo(
- () =>
- (data ?? [])
- // El filtro que pide la card: solo services con integración existente y
- // activa. Los demás son servicios disponibles, no nodos de la empresa.
- .filter((integration) => integration.configured && integration.isActive)
- .map((integration) => toNode(integration, now))
- .sort((a, b) => a.name.localeCompare(b.name, 'es-AR')),
- [data, now],
- )
-
- const counts = useMemo(() => {
- const online = nodes.filter((node) => node.status === 'online').length
- const reporting = nodes.filter((node) => node.status !== 'unknown').length
-
- return { online, reporting }
- }, [nodes])
-
- // Denominador = nodos que reportan, no todos los activos. Mientras el backend
- // no mande la marca de sync, `reporting` es 0 y el KPI queda en `null` ("—"):
- // preferimos no saber antes que mostrar 0% (falsa caída total) o 100% (falsa
- // salud) sobre un dato que no existe.
- const healthPercentage =
- counts.reporting === 0 ? null : Math.round((counts.online / counts.reporting) * 100)
-
- return {
- nodes,
- activeNodes: nodes.length,
- reportingNodes: counts.reporting,
- onlineNodes: counts.online,
- healthPercentage,
- healthTone: resolveTone(healthPercentage),
- isLoading,
- isError,
- error,
- refetch: retry,
- }
-}
diff --git a/src/features/dashboard/hooks/useInventoryAlerts.test.tsx b/src/features/dashboard/hooks/useInventoryAlerts.test.tsx
new file mode 100644
index 0000000..50924a0
--- /dev/null
+++ b/src/features/dashboard/hooks/useInventoryAlerts.test.tsx
@@ -0,0 +1,62 @@
+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 { useInventoryAlerts } from './useInventoryAlerts'
+
+const LOADS = [
+ { id: 1, name: 'CD Norte', storedUnits: 200, capacity: null },
+ { id: 2, name: 'CD Sur', storedUnits: 50, capacity: null },
+]
+
+function wrapper({ children }: { children: ReactNode }) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+
+ return {children}
+}
+
+beforeEach(() => {
+ vi.restoreAllMocks()
+ vi.spyOn(api, 'fetchStockAlertCounts').mockResolvedValue({ low: 18, outOfStock: 6 })
+})
+
+describe('useInventoryAlerts', () => {
+ it('adds up the units of every warehouse', async () => {
+ vi.spyOn(api, 'fetchWarehouseLoads').mockResolvedValue(LOADS)
+ const { result } = renderHook(() => useInventoryAlerts(), { wrapper })
+
+ await waitFor(() => expect(result.current.storedUnits).toBe(250))
+ })
+
+ // Sumar sobre la lista vacía daría 0, y un cero es un dato real: la empresa
+ // no guarda nada. Que la consulta se haya caído no es eso.
+ it('has no number at all when the warehouses never arrived', async () => {
+ vi.spyOn(api, 'fetchWarehouseLoads').mockRejectedValue(new Error('boom'))
+ const { result } = renderHook(() => useInventoryAlerts(), { wrapper })
+
+ await waitFor(() => expect(result.current.isError).toBe(true))
+ expect(result.current.storedUnits).toBeUndefined()
+ })
+
+ // Que se caiga el listado de depósitos no tiene por qué dejar sin número a la
+ // tarjeta de alertas: son dos preguntas distintas.
+ it('keeps the alert count when only the warehouses failed', async () => {
+ vi.spyOn(api, 'fetchWarehouseLoads').mockRejectedValue(new Error('boom'))
+ const { result } = renderHook(() => useInventoryAlerts(), { wrapper })
+
+ await waitFor(() => expect(result.current.alerts.value).toBe(24))
+ })
+
+ // Una empresa sin depósitos sí guarda cero unidades, y eso se dice.
+ it('says zero for a company that really has no warehouses', async () => {
+ vi.spyOn(api, 'fetchWarehouseLoads').mockResolvedValue([])
+ const { result } = renderHook(() => useInventoryAlerts(), { wrapper })
+
+ await waitFor(() => expect(result.current.storedUnits).toBe(0))
+ })
+})
diff --git a/src/features/dashboard/hooks/useInventoryAlerts.ts b/src/features/dashboard/hooks/useInventoryAlerts.ts
index 461de9d..f13cb20 100644
--- a/src/features/dashboard/hooks/useInventoryAlerts.ts
+++ b/src/features/dashboard/hooks/useInventoryAlerts.ts
@@ -17,8 +17,12 @@ export interface InventoryOverview {
breakdown: StockAlertCounts | undefined
/** Depósitos ordenados de más a menos cargado, con el ancho de su barra. */
warehouses: WarehouseShare[]
- /** Unidades guardadas entre todos los depósitos. */
- storedUnits: number
+ /**
+ * Unidades guardadas entre todos los depósitos, o `undefined` si el listado
+ * no llegó. Sumar sobre una lista vacía daría 0, que es un dato real —la
+ * empresa no guarda nada— y acá no lo sabemos.
+ */
+ storedUnits: number | undefined
warehousesLoading: boolean
/** Alguna de las dos consultas falló. */
isError: boolean
@@ -78,7 +82,7 @@ export function useInventoryAlerts(): InventoryOverview {
},
breakdown: alerts.data,
warehouses: warehouseShares(loads),
- storedUnits: totalStoredUnits(loads),
+ storedUnits: warehouses.data === undefined ? undefined : totalStoredUnits(warehouses.data),
warehousesLoading: warehouses.isLoading,
isError: alerts.isError || warehouses.isError,
refetch,
diff --git a/src/features/dashboard/hooks/useNow.ts b/src/features/dashboard/hooks/useNow.ts
deleted file mode 100644
index f273720..0000000
--- a/src/features/dashboard/hooks/useNow.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useEffect, useState } from 'react'
-
-// Reloj de UI: re-renderiza cada `intervalMs` para que el tiempo relativo del
-// nodo ("hace 2 minutos") avance y el LED cruce el umbral de frescura solo,
-// sin depender de un refetch. Es lo que pide el criterio "el LED reacciona si
-// la fecha de actualización es demasiado antigua": la fecha no cambia, cambia
-// el ahora contra el que la comparamos.
-//
-// No contradice la regla de "nunca useEffect + useState": esa aplica al fetch
-// de datos (que va por React Query). Acá no se busca nada al servidor, es un
-// tick local.
-export function useNow(intervalMs: number): number {
- const [now, setNow] = useState(() => Date.now())
-
- useEffect(() => {
- const id = window.setInterval(() => setNow(Date.now()), intervalMs)
- return () => window.clearInterval(id)
- }, [intervalMs])
-
- return now
-}
diff --git a/src/features/dashboard/hooks/useRecentShipments.ts b/src/features/dashboard/hooks/useRecentShipments.ts
new file mode 100644
index 0000000..2d139ec
--- /dev/null
+++ b/src/features/dashboard/hooks/useRecentShipments.ts
@@ -0,0 +1,39 @@
+import { useQuery } from '@tanstack/react-query'
+
+import { fetchRecentShipments, RECENT_SHIPMENTS } from '../api'
+import { shipmentKeys } from '../queryKeys'
+import type { DispatchedShipment } from '../types'
+
+export interface RecentShipmentsState {
+ shipments: DispatchedShipment[]
+ isLoading: boolean
+ isError: boolean
+ refetch: () => void
+}
+
+/**
+ * Los últimos envíos que muestra el panel (TESIS-163).
+ *
+ * Reemplaza a la tarjeta de Integraciones, que mostraba la salud de los nodos.
+ * Mismo criterio que `useRecentOrders`: consulta propia y no una porción del
+ * listado, porque «los últimos cinco» y «la página N filtrada» son dos
+ * preguntas distintas y compartir la caché las mezclaría.
+ */
+export function useRecentShipments(): RecentShipmentsState {
+ const query = useQuery({
+ queryKey: shipmentKeys.recent(RECENT_SHIPMENTS),
+ queryFn: fetchRecentShipments,
+ })
+
+ const { refetch } = query
+
+ return {
+ shipments: query.data ?? [],
+ isLoading: query.isLoading,
+ isError: query.isError,
+ // Envuelto por lo mismo que en los otros hooks del panel: el `refetch` de
+ // React Query recibe opciones, y cablearlo a un `onClick` le pasaría el
+ // MouseEvent como opciones.
+ refetch: () => void refetch(),
+ }
+}
diff --git a/src/features/dashboard/index.ts b/src/features/dashboard/index.ts
index a1c7cfe..3ce3bc4 100644
--- a/src/features/dashboard/index.ts
+++ b/src/features/dashboard/index.ts
@@ -1,10 +1,9 @@
export { DashboardPage } from './pages/DashboardPage'
-export { useInfraHealth } from './hooks/useInfraHealth'
-export type { InfraHealth, InfraNode } from './hooks/useInfraHealth'
export { useInventoryAlerts } from './hooks/useInventoryAlerts'
export type { InventoryOverview } from './hooks/useInventoryAlerts'
export { useLogisticsKpis } from './hooks/useLogisticsKpis'
export { useRecentOrders } from './hooks/useRecentOrders'
+export { useRecentShipments } from './hooks/useRecentShipments'
+export type { RecentShipmentsState } from './hooks/useRecentShipments'
export type { RecentOrdersState } from './hooks/useRecentOrders'
export type { KpiState, LogisticsKpis } from './hooks/useLogisticsKpis'
-export type { NodeSyncStatus } from './types'
diff --git a/src/features/dashboard/pages/DashboardPage.test.tsx b/src/features/dashboard/pages/DashboardPage.test.tsx
index 8c6cb10..1e31a2c 100644
--- a/src/features/dashboard/pages/DashboardPage.test.tsx
+++ b/src/features/dashboard/pages/DashboardPage.test.tsx
@@ -3,16 +3,16 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithTheme } from '../../../test/renderWithTheme'
-import { useInfraHealth } from '../hooks/useInfraHealth'
import { useInventoryAlerts } from '../hooks/useInventoryAlerts'
import { useLogisticsKpis } from '../hooks/useLogisticsKpis'
import { useRecentOrders } from '../hooks/useRecentOrders'
+import { useRecentShipments } from '../hooks/useRecentShipments'
import type { RecentOrder } from '../types'
import { DashboardPage } from './DashboardPage'
vi.mock('../hooks/useLogisticsKpis', () => ({ useLogisticsKpis: vi.fn() }))
-vi.mock('../hooks/useInfraHealth', () => ({ useInfraHealth: vi.fn() }))
+vi.mock('../hooks/useRecentShipments', () => ({ useRecentShipments: vi.fn() }))
vi.mock('../hooks/useInventoryAlerts', () => ({ useInventoryAlerts: vi.fn() }))
vi.mock('../hooks/useRecentOrders', () => ({ useRecentOrders: vi.fn() }))
@@ -48,8 +48,22 @@ function mockOrders(overrides: Partial> = {})
}
const WAREHOUSES = [
- { id: 1, name: 'CD Norte', storedUnits: 200, share: 100 },
- { id: 2, name: 'CD Sur', storedUnits: 50, share: 25 },
+ {
+ id: 1,
+ name: 'CD Norte',
+ storedUnits: 200,
+ capacity: null,
+ share: 100,
+ measuredAgainstCapacity: false,
+ },
+ {
+ id: 2,
+ name: 'CD Sur',
+ storedUnits: 50,
+ capacity: null,
+ share: 25,
+ measuredAgainstCapacity: false,
+ },
]
// 18 por debajo del umbral y 6 agotados: 24 en alerta. El desglose importa
@@ -87,20 +101,45 @@ beforeEach(() => {
isError: false,
refetch: vi.fn(),
})
- vi.mocked(useInfraHealth).mockReturnValue({
- nodes: [],
- reportingNodes: 0,
- onlineNodes: 0,
- healthPercentage: null,
- healthTone: 'neutral',
+ vi.mocked(useRecentShipments).mockReturnValue({
+ shipments: [],
isLoading: false,
isError: false,
refetch: vi.fn(),
- } as never)
+ })
mockInventory()
mockOrders()
})
+// Una lista de depósitos que no llegó no es una empresa sin unidades: sumar
+// sobre la lista vacía daría 0, que es un dato real y acá no lo sabemos.
+describe('DashboardPage · stored units when the warehouses never arrived', () => {
+ it('shows no number instead of claiming zero units', () => {
+ mockInventory({ storedUnits: undefined, warehouses: [], isError: true })
+ renderPage()
+
+ const card = screen.getByText('Unidades en stock').closest('div')
+
+ expect(within(card as HTMLElement).queryByText('0')).not.toBeInTheDocument()
+ expect(within(card as HTMLElement).getByText('—')).toBeInTheDocument()
+ })
+
+ it('does not say the company has zero warehouses either', () => {
+ mockInventory({ storedUnits: undefined, warehouses: [], isError: true })
+ renderPage()
+
+ expect(screen.queryByText(/repartidas en 0 depósitos/)).not.toBeInTheDocument()
+ })
+
+ it('says the warehouse load could not be loaded, not that there are none', () => {
+ mockInventory({ storedUnits: undefined, warehouses: [], isError: true })
+ renderPage()
+
+ expect(screen.getByText('No pudimos cargar la carga por depósito.')).toBeInTheDocument()
+ expect(screen.queryByText('La empresa no tiene depósitos cargados.')).not.toBeInTheDocument()
+ })
+})
+
describe('DashboardPage · inventory alerts', () => {
// El agotado también es alerta: `low` en el backend es `BETWEEN 1 AND umbral`,
// así que contar sólo eso dejaba afuera los productos que ya no se venden.
@@ -223,12 +262,62 @@ describe('DashboardPage · warehouse load', () => {
expect(screen.getByText('50 u')).toBeInTheDocument()
})
- // Lo que evita que alguien lea ocupación donde hay una comparación.
- it('says out loud what the bar is measured against', () => {
+ // Lo que evita que alguien lea ocupación donde hay una comparación: desde
+ // TESIS-162 cada fila dice contra qué se mide, porque las dos barras se ven
+ // igual y significan cosas distintas.
+ it('shows the units of a warehouse that declared no capacity', () => {
+ renderPage()
+
+ expect(screen.getByText('200 u')).toBeInTheDocument()
+ expect(screen.queryByText(/de su capacidad/)).not.toBeInTheDocument()
+ })
+
+ // «200 u» no dice contra qué se mide la barra. El rótulo accesible lo dice
+ // por fila; abajo de la lista se dice también para quien mira.
+ it('says what the bars without a declared capacity are measured against', () => {
+ renderPage()
+
+ expect(
+ screen.getByText('Los depósitos sin capacidad declarada se comparan con el más cargado.'),
+ ).toBeInTheDocument()
+ })
+
+ it('does not say it when every warehouse declared its capacity', () => {
+ mockInventory({
+ warehouses: [
+ {
+ id: 1,
+ name: 'CD Norte',
+ storedUnits: 200,
+ capacity: 1000,
+ share: 20,
+ measuredAgainstCapacity: true,
+ },
+ ],
+ })
+ renderPage()
+
+ expect(screen.queryByText(/se comparan con el más cargado/)).not.toBeInTheDocument()
+ })
+
+ it('shows the occupancy of a warehouse that declared one', () => {
+ mockInventory({
+ warehouses: [
+ {
+ id: 1,
+ name: 'CD Norte',
+ storedUnits: 200,
+ capacity: 1_000,
+ share: 20,
+ measuredAgainstCapacity: true,
+ },
+ ],
+ })
renderPage()
+ expect(screen.getByText('20 % de su capacidad')).toBeInTheDocument()
expect(
- screen.getByText(/250 unidades guardadas · la barra compara contra el depósito más cargado/),
+ screen.getByLabelText('CD Norte: 200 de 1.000 unidades de capacidad'),
).toBeInTheDocument()
})
diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx
index 52083d2..a2dff97 100644
--- a/src/features/dashboard/pages/DashboardPage.tsx
+++ b/src/features/dashboard/pages/DashboardPage.tsx
@@ -1,22 +1,23 @@
+import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
-import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined'
import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined'
import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined'
import { Alert, Box, Button, Grid, Stack, Typography } from '@mui/material'
import { Link as RouterLink } from 'react-router-dom'
import { PageWrapper, StatCard } from 'shared/components'
+import { formatInteger } from 'shared/utils'
-import { IntegrationNodeList } from '../components/IntegrationNodeList'
import { RecentOrdersTable } from '../components/RecentOrdersTable'
+import { RecentShipmentsCard } from '../components/RecentShipmentsCard'
import { WarehouseLoadCard } from '../components/WarehouseLoadCard'
import { dashboardCopy } from '../content'
-import { useInfraHealth } from '../hooks/useInfraHealth'
import { useInventoryAlerts } from '../hooks/useInventoryAlerts'
import { useLogisticsKpis } from '../hooks/useLogisticsKpis'
import { useRecentOrders } from '../hooks/useRecentOrders'
+import { useRecentShipments } from '../hooks/useRecentShipments'
-const { metrics, infra, error: errorCopy } = dashboardCopy
-const healthCopy = infra.health
+const { metrics, error: errorCopy } = dashboardCopy
+const storedUnitsCopy = metrics.storedUnits
const alertsCopy = metrics.inventoryAlerts
// Destino del click en la tarjeta de alertas. Las rutas se registran en
@@ -58,12 +59,10 @@ function noteFor(breakdown: { low: number; outOfStock: number } | undefined): st
// `StatCard` recibe el valor ya formateado: el componente del DS no decide
// separadores ni unidades.
-const NUMBER_FORMAT = new Intl.NumberFormat('es-AR')
-
// Un conteo que no llegó (consulta fallida) se muestra como "—", nunca como 0:
// un cero es un dato real —no hay órdenes pendientes— y acá no lo sabemos.
function formatCount(count: number | undefined): string {
- return count === undefined ? metrics.unknownValue : NUMBER_FORMAT.format(count)
+ return count === undefined ? metrics.unknownValue : formatInteger(count)
}
export function DashboardPage() {
@@ -74,17 +73,6 @@ export function DashboardPage() {
refetch: refetchKpis,
} = useLogisticsKpis()
- const {
- nodes,
- reportingNodes,
- onlineNodes,
- healthPercentage,
- healthTone,
- isLoading: isInfraLoading,
- isError: isInfraError,
- refetch: refetchInfra,
- } = useInfraHealth()
-
const {
alerts,
breakdown,
@@ -102,13 +90,20 @@ export function DashboardPage() {
refetch: refetchOrders,
} = useRecentOrders()
- const isError = isKpisError || isInfraError || isInventoryError || isOrdersError
+ const {
+ shipments,
+ isLoading: shipmentsLoading,
+ isError: isShipmentsError,
+ refetch: refetchShipments,
+ } = useRecentShipments()
+
+ const isError = isKpisError || isInventoryError || isOrdersError || isShipmentsError
const retry = () => {
refetchKpis()
- refetchInfra()
refetchInventory()
refetchOrders()
+ refetchShipments()
}
// El tono de alerta se enciende sólo si hay algo que alertar: con cero
@@ -118,14 +113,6 @@ export function DashboardPage() {
const hasAlerts = alerts.value !== undefined && alerts.value > 0
const alertsValue = formatCount(alerts.value)
- // Sin nodos reportando sync, el KPI no tiene numerador ni denominador reales:
- // se muestra "—" en vez de un 0% que se leería como caída total de la
- // infraestructura, o un 100% que afirmaría una salud que nadie verificó.
- const healthValue =
- isInfraLoading || healthPercentage === null
- ? healthCopy.unknownValue
- : `${NUMBER_FORMAT.format(healthPercentage)}%`
-
return (
@@ -170,11 +157,19 @@ export function DashboardPage() {
/>
+ {/* Reemplaza al KPI de «Salud del sistema» (TESIS-163). Sale del
+ mismo dato que la carga por depósito, así que no agrega ningún
+ request. */}
}
- tone={healthTone}
+ label={storedUnitsCopy.label}
+ value={formatCount(storedUnits)}
+ loading={warehousesLoading}
+ icon={}
+ note={
+ warehousesLoading || storedUnits === undefined
+ ? undefined
+ : storedUnitsCopy.note(warehouses.length)
+ }
/>
@@ -203,7 +198,8 @@ export function DashboardPage() {
{/* La fila inferior del diseño: la tabla de órdenes recientes y, a su
- derecha, la columna de 280px con integraciones y carga de depósitos.
+ derecha, la columna de 280px con los últimos envíos y la carga de
+ depósitos.
En pantallas angostas la columna baja debajo de la tabla. */}
@@ -211,12 +207,7 @@ export function DashboardPage() {
-
+ [...shipmentKeys.all, 'kpi', status] as const,
+ // Mismo criterio que `orderKeys.recent`: despachar un envío invalida el
+ // dominio y refresca también la tarjeta del panel.
+ recent: (size: number) => [...shipmentKeys.all, 'recent', size] as const,
}
// Misma regla que los KPIs de órdenes y envíos: la key cuelga de la raíz del
diff --git a/src/features/dashboard/types.ts b/src/features/dashboard/types.ts
index 8998ecf..9ce74dc 100644
--- a/src/features/dashboard/types.ts
+++ b/src/features/dashboard/types.ts
@@ -1,22 +1,40 @@
-// Estado de frescura derivado de `last_synced_at`:
-// - `online` → sincronizó dentro de la ventana esperada (ícono `check_circle`)
-// - `stale` → la última sync quedó fuera de la ventana (ícono `error`)
-// - `unknown` → no hay marca de sync: no afirmamos nada (ícono neutro)
-export type NodeSyncStatus = 'online' | 'stale' | 'unknown'
-
/**
* Un depósito y las unidades que guarda, para el widget de carga del panel
* (TESIS-55).
*
- * No hay porcentaje de ocupación y no es un olvido: `warehouses` no tiene
- * ninguna capacidad máxima contra la cual medirlo. La barra del widget compara
- * los depósitos entre sí, y de eso se encarga `warehouseShares`.
+ * Desde TESIS-162 el depósito puede declarar su capacidad, y entonces la barra
+ * mide ocupación de verdad. Sin capacidad declarada sigue comparando los
+ * depósitos entre sí, que es la única comparación real que queda.
*/
export interface WarehouseLoad {
id: number
name: string
/** Suma de las unidades en stock del depósito. Cero es un dato, no un faltante. */
storedUnits: number
+ /**
+ * Capacidad declarada, en unidades. `null` cuando nadie la cargó: no es cero,
+ * que diría que no entra nada.
+ */
+ capacity: number | null
+}
+
+/** Los cuatro estados del envío (`Shipment::STATUSES`). */
+export type ShipmentStatus = 'pending' | 'ready_to_ship' | 'in_transit' | 'delivered'
+
+/**
+ * Una fila de la tarjeta «Últimos envíos» del panel (TESIS-163).
+ *
+ * Reemplaza a la tarjeta de Integraciones, que mostraba la salud de los nodos.
+ * `trackingNumber` y `courier` llegan vacíos mientras el envío está `pending`:
+ * los dos los asigna el courier al confirmar el despacho (TESIS-47).
+ */
+export interface DispatchedShipment {
+ id: number
+ orderId: number
+ status: ShipmentStatus
+ trackingNumber: string | null
+ courier: string | null
+ createdAt: string
}
/** Los tres estados que el backend acepta (`Order::STATUSES`). */
diff --git a/src/features/dashboard/utils/capacity.test.ts b/src/features/dashboard/utils/capacity.test.ts
index a2ae4fa..72e3126 100644
--- a/src/features/dashboard/utils/capacity.test.ts
+++ b/src/features/dashboard/utils/capacity.test.ts
@@ -4,8 +4,13 @@ import type { WarehouseLoad } from '../types'
import { totalStoredUnits, warehouseShares } from './capacity'
-function warehouse(id: number, name: string, storedUnits: number): WarehouseLoad {
- return { id, name, storedUnits }
+function warehouse(
+ id: number,
+ name: string,
+ storedUnits: number,
+ capacity: number | null = null,
+): WarehouseLoad {
+ return { id, name, storedUnits, capacity }
}
describe('warehouseShares', () => {
@@ -62,3 +67,60 @@ describe('totalStoredUnits', () => {
expect(totalStoredUnits([])).toBe(0)
})
})
+
+// TESIS-162: con capacidad declarada la barra deja de comparar depósitos y
+// pasa a medir ocupación.
+describe('warehouseShares with a declared capacity', () => {
+ it('measures the warehouse against its own capacity', () => {
+ const shares = warehouseShares([warehouse(1, 'Norte', 300, 1_000)])
+
+ expect(shares[0]).toMatchObject({ share: 30, measuredAgainstCapacity: true })
+ })
+
+ // Un depósito pasado de su techo está lleno, no 140 % lleno.
+ it('caps a warehouse that holds more than it declared', () => {
+ const shares = warehouseShares([warehouse(1, 'Norte', 1_400, 1_000)])
+
+ expect(shares[0].share).toBe(100)
+ })
+
+ // Una empresa puede haber declarado unos y otros no.
+ it('keeps comparing the ones that declared nothing against the fullest', () => {
+ const shares = warehouseShares([
+ warehouse(1, 'Norte', 200, 1_000),
+ warehouse(2, 'Sur', 50),
+ warehouse(3, 'Centro', 100),
+ ])
+
+ expect(shares.find((w) => w.name === 'Norte')).toMatchObject({
+ share: 20,
+ measuredAgainstCapacity: true,
+ })
+ expect(shares.find((w) => w.name === 'Sur')).toMatchObject({
+ share: 25,
+ measuredAgainstCapacity: false,
+ })
+ })
+})
+
+// Un techo en cero no define ninguna ocupación, y `0 / 0` daría `NaN` en el
+// ancho de la barra.
+describe('warehouseShares with a capacity of zero', () => {
+ it('does not turn the bar into NaN', () => {
+ const shares = warehouseShares([warehouse(1, 'Norte', 0, 0)])
+
+ expect(Number.isFinite(shares[0].share)).toBe(true)
+ expect(shares[0].share).toBe(0)
+ })
+
+ // Sin techo utilizable la barra vuelve a comparar contra el más cargado, y
+ // el rótulo tiene que decir eso y no «% de su capacidad».
+ it('measures it against the fullest warehouse instead', () => {
+ const shares = warehouseShares([warehouse(1, 'Norte', 50, 0), warehouse(2, 'Sur', 100)])
+
+ expect(shares.find((w) => w.name === 'Norte')).toMatchObject({
+ share: 50,
+ measuredAgainstCapacity: false,
+ })
+ })
+})
diff --git a/src/features/dashboard/utils/capacity.ts b/src/features/dashboard/utils/capacity.ts
index 13cd728..155ddc3 100644
--- a/src/features/dashboard/utils/capacity.ts
+++ b/src/features/dashboard/utils/capacity.ts
@@ -5,20 +5,30 @@ import type { WarehouseLoad } from '../types'
/** Un depósito con el ancho que le toca en la barra. */
export interface WarehouseShare extends WarehouseLoad {
- /** 0-100. Proporción respecto del depósito más cargado, no de una capacidad. */
+ /**
+ * 0-100. Ocupación real si el depósito declaró su capacidad (TESIS-162); si
+ * no, proporción respecto del depósito más cargado.
+ */
share: number
+ /** Qué mide la barra de esta fila, para poder decirlo en su rótulo. */
+ measuredAgainstCapacity: boolean
}
/**
* Los depósitos ordenados de más a menos cargado, con el ancho de cada barra.
*
- * La referencia del 100% es el depósito más cargado y no una capacidad máxima:
- * `warehouses` no tiene ninguna, así que un porcentaje de ocupación sería un
- * número inventado. Lo que la barra dice es «este guarda la mitad que el que
- * más guarda», que es una comparación real.
+ * Con capacidad declarada (TESIS-162) la barra mide **ocupación**: cuánto de lo
+ * que entra está ocupado. Sin capacidad la referencia sigue siendo el depósito
+ * más cargado, porque esa es la única comparación real que queda: un porcentaje
+ * de ocupación sin techo sería un número inventado.
+ *
+ * Los dos casos conviven: una empresa puede haber declarado la capacidad de
+ * algunos depósitos y no de otros, y cada fila dice contra qué se mide.
*
* Con todos los depósitos vacíos el máximo es cero: todas las barras quedan en
- * cero en vez de dividir por cero y dar `NaN`.
+ * cero en vez de dividir por cero y dar `NaN`. Por encima de la capacidad la
+ * barra se corta en 100: un depósito pasado de su techo está lleno, no 140 %
+ * lleno.
*/
export function warehouseShares(warehouses: WarehouseLoad[]): WarehouseShare[] {
const busiest = Math.max(0, ...warehouses.map((warehouse) => warehouse.storedUnits))
@@ -27,10 +37,21 @@ export function warehouseShares(warehouses: WarehouseLoad[]): WarehouseShare[] {
.sort((a, b) => b.storedUnits - a.storedUnits)
.map((warehouse) => ({
...warehouse,
- share: busiest === 0 ? 0 : (warehouse.storedUnits / busiest) * 100,
+ share: shareOf(warehouse, busiest),
+ measuredAgainstCapacity: warehouse.capacity !== null && warehouse.capacity > 0,
}))
}
+function shareOf(warehouse: WarehouseLoad, busiest: number): number {
+ // `> 0` y no `!== null`: un techo en cero no define ninguna ocupación, y
+ // `0 / 0` daría `NaN` en el ancho de la barra.
+ if (warehouse.capacity !== null && warehouse.capacity > 0) {
+ return Math.min(100, (warehouse.storedUnits / warehouse.capacity) * 100)
+ }
+
+ return busiest === 0 ? 0 : (warehouse.storedUnits / busiest) * 100
+}
+
/** Unidades guardadas entre todos los depósitos. Es el epígrafe de la tarjeta. */
export function totalStoredUnits(warehouses: WarehouseLoad[]): number {
return warehouses.reduce((total, warehouse) => total + warehouse.storedUnits, 0)
diff --git a/src/features/dashboard/utils/recentOrders.test.ts b/src/features/dashboard/utils/recentOrders.test.ts
index b3e6eef..1c8e2d9 100644
--- a/src/features/dashboard/utils/recentOrders.test.ts
+++ b/src/features/dashboard/utils/recentOrders.test.ts
@@ -1,30 +1,10 @@
import { describe, expect, it } from 'vitest'
-import { formatMoney, formatOrderId, formatShortDate, formatTime } from './recentOrders'
+import { formatOrderId, formatShortDate, formatTime } from './recentOrders'
// `toContain` y no `toBe`: entre el símbolo y el número, `Intl` mete un espacio
// duro (U+00A0) que en la comparación no se distingue de uno común y deja un
// diff ilegible — «expected '$ 0,00' to be '$ 0,00'».
-describe('formatMoney', () => {
- // Criterio de finalización de la card: el monto se formatea como moneda.
- it('writes the amount with the thousands separators of the locale', () => {
- expect(formatMoney(1478300.49)).toContain('1.478.300,49')
- })
-
- it('leads with the currency symbol', () => {
- expect(formatMoney(1478300.49)).toMatch(/^\$/)
- })
-
- // Redondear en pantalla sería mostrar un número que no es el de la orden.
- it('never drops the cents, even when they are zero', () => {
- expect(formatMoney(890050)).toContain('890.050,00')
- })
-
- it('handles an order that adds up to nothing', () => {
- expect(formatMoney(0)).toContain('0,00')
- })
-})
-
describe('formatOrderId', () => {
it('prefers the id of the external channel, which is how the operator knows it', () => {
expect(formatOrderId('ORD-8829-X', 412)).toBe('#ORD-8829-X')
diff --git a/src/features/dashboard/utils/recentOrders.ts b/src/features/dashboard/utils/recentOrders.ts
index 0027c01..9892bac 100644
--- a/src/features/dashboard/utils/recentOrders.ts
+++ b/src/features/dashboard/utils/recentOrders.ts
@@ -5,20 +5,11 @@ import type { OrderStatus } from '../types'
// Formato y vocabulario de la tabla de órdenes recientes del panel.
//
-// Sí, esto también existe en `features/orders/utils`. No se importa de ahí
-// porque una feature no puede depender de otra (architecture.md §3.2), y no se
-// sube a `shared/` en esta card por una razón concreta: TESIS-61 está
-// reescribiendo esos archivos en paralelo, y moverlos ahora garantiza un
-// conflicto en el trabajo de otro. Cuando esa card entre, converger las dos
-// copias es una card de seguimiento — hasta entonces, las etiquetas de estado
-// son las mismas de los dos lados a propósito y este comentario es el recordatorio.
-
-const MONEY = new Intl.NumberFormat('es-AR', {
- style: 'currency',
- currency: 'ARS',
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
-})
+// El importe y las unidades salen de `shared/utils`: eran la misma cuenta que
+// en `features/orders/utils`, y una feature no puede importar de otra
+// (architecture.md §3.2). Converger las dos copias era la card de seguimiento
+// que ese comentario anunciaba, y es TESIS-166. Las etiquetas de estado siguen
+// repetidas a propósito: son vocabulario de pantalla, no formato.
const DATE = new Intl.DateTimeFormat('es-AR', { day: 'numeric', month: 'short' })
@@ -52,17 +43,6 @@ export function statusLabel(status: OrderStatus): string {
return LABELS[status]
}
-/**
- * Importe de la orden, con dos decimales siempre.
- *
- * El criterio de finalización de la card es exactamente éste: la columna es
- * plata facturada, y redondear $ 1.478.300,49 a $ 1.478.300 en pantalla es
- * mostrar un número que no es el de la orden.
- */
-export function formatMoney(amount: number): string {
- return MONEY.format(amount)
-}
-
/** "24 ago" — la línea principal de la columna de fecha, sin año como el diseño. */
export function formatShortDate(iso: string): string {
return DATE.format(new Date(iso))
diff --git a/src/features/failed-events/content.ts b/src/features/failed-events/content.ts
index bcda153..53926d1 100644
--- a/src/features/failed-events/content.ts
+++ b/src/features/failed-events/content.ts
@@ -1,15 +1,10 @@
import type { StatusVariant } from 'shared/components'
+import { formatInteger } from 'shared/utils'
import type { FailedEventDirection, FailedEventStatus } from './types'
// Copy centralizado de la feature — sin literales sueltos en el JSX.
-const numberFormat = new Intl.NumberFormat('es-AR')
-
-export function formatCount(value: number): string {
- return numberFormat.format(value)
-}
-
/**
* Nombre legible de cada tipo de evento (`Webhooks::ReplayRegistry`). Un tipo
* que no está acá se muestra tal cual: es mejor un nombre técnico que
@@ -102,6 +97,6 @@ export const failedEventsCopy = {
next: 'Página siguiente',
page: (page: number) => `Ir a la página ${page}`,
summary: (from: number, to: number, total: number) =>
- `Mostrando ${from} a ${to} de ${formatCount(total)} ${total === 1 ? 'evento' : 'eventos'}`,
+ `Mostrando ${from} a ${to} de ${formatInteger(total)} ${total === 1 ? 'evento' : 'eventos'}`,
},
}
diff --git a/src/features/failed-events/pages/FailedEventsPage.tsx b/src/features/failed-events/pages/FailedEventsPage.tsx
index 9f786af..0f28708 100644
--- a/src/features/failed-events/pages/FailedEventsPage.tsx
+++ b/src/features/failed-events/pages/FailedEventsPage.tsx
@@ -11,15 +11,9 @@ import {
} from 'shared/components'
import type { DataTableColumn, DataTableTab } from 'shared/components'
import { notify } from 'shared/store'
-import { formatRelativeTime } from 'shared/utils'
+import { formatRelativeTime, formatInteger } from 'shared/utils'
-import {
- DIRECTION_LABELS,
- STATUS_LABELS,
- STATUS_VARIANTS,
- failedEventsCopy,
- formatCount,
-} from '../content'
+import { DIRECTION_LABELS, STATUS_LABELS, STATUS_VARIANTS, failedEventsCopy } from '../content'
import {
NOT_REQUEUEABLE_STATUS,
QUEUE_TABS,
@@ -175,7 +169,7 @@ export function FailedEventsPage() {
return {
id,
label: tabCopy[id],
- ...(count === undefined ? {} : { count: formatCount(count) }),
+ ...(count === undefined ? {} : { count: formatInteger(count) }),
}
})
diff --git a/src/features/inventory/api.ts b/src/features/inventory/api.ts
index 7cc129d..3bb892b 100644
--- a/src/features/inventory/api.ts
+++ b/src/features/inventory/api.ts
@@ -1,6 +1,7 @@
import { client } from 'shared/api/client'
import type {
+ CatalogCounts,
CreateProductPayload,
LinkProductPayload,
LinkProductResult,
@@ -31,11 +32,13 @@ interface ApiWarehouse {
name: string
address: string
zip_code: string
+ capacity?: number | null
}
interface ApiStock {
id: number
quantity: number
+ committed?: number
warehouse_id: number
warehouse: ApiWarehouse
stock_status: StockStatus
@@ -59,6 +62,8 @@ interface ApiProduct {
name: string
description: string | null
category: string | null
+ packaging: string | null
+ technical_standard: string | null
weight: number
dimensions: string | null
total_stock: number
@@ -66,6 +71,10 @@ interface ApiProduct {
in_transit_quantity: number
in_transit_by_warehouse: { warehouse_id: number; name: string; quantity: number }[]
updated_at: string
+ committed_quantity: number
+ on_hand_quantity: number
+ available_to_promise: number
+ committed_by_warehouse: { warehouse_id: number; name: string; quantity: number }[]
stocks: ApiStock[]
}
@@ -75,7 +84,12 @@ interface ApiList {
}
function toWarehouse(warehouse: ApiWarehouse): Warehouse {
- return { id: warehouse.id, name: warehouse.name, address: warehouse.address }
+ return {
+ id: warehouse.id,
+ name: warehouse.name,
+ address: warehouse.address,
+ capacity: warehouse.capacity ?? null,
+ }
}
// El ETag viene entrecomillado y puede traer el prefijo débil `W/`. Se guarda
@@ -85,12 +99,22 @@ function readVersion(etag: unknown): string | null {
}
function toProduct(product: ApiProduct, version: string | null = null): Product {
+ // Lo comprometido llega aparte y por depósito: un depósito puede tener
+ // unidades vendidas sin despachar y ninguna fila de stock, si la venta se
+ // llevó la última. Se indexa por depósito para resolver cada fila sin
+ // recorrer la lista entera por cada una.
+ const committedByWarehouse = new Map(
+ (product.committed_by_warehouse ?? []).map((row) => [row.warehouse_id, row.quantity]),
+ )
+
return {
id: product.id,
sku: product.sku,
name: product.name,
description: product.description,
category: (product.category ?? null) as Product['category'],
+ packaging: product.packaging,
+ technicalStandard: product.technical_standard,
weight: product.weight,
dimensions: product.dimensions,
totalStock: product.total_stock,
@@ -101,11 +125,21 @@ function toProduct(product: ApiProduct, version: string | null = null): Product
name: transit.name,
quantity: transit.quantity,
})),
+ committedByWarehouse: (product.committed_by_warehouse ?? []).map((row) => ({
+ warehouseId: row.warehouse_id,
+ name: row.name,
+ quantity: row.quantity,
+ })),
updatedAt: product.updated_at,
+ committed: product.committed_quantity,
+ onHand: product.on_hand_quantity,
+ availableToPromise: product.available_to_promise,
+ inTransit: product.in_transit_quantity,
version,
stocks: (product.stocks ?? []).map((stock) => ({
warehouseId: stock.warehouse_id,
quantity: stock.quantity,
+ committed: committedByWarehouse.get(stock.warehouse_id) ?? 0,
warehouse: toWarehouse(stock.warehouse),
stockStatus: stock.stock_status,
})),
@@ -128,10 +162,12 @@ function toSummary(product: ApiProductSummary): ProductSummary {
// Un filtro vacío no viaja: mandar `status=` o `search=` en blanco haría que el
// backend filtre por cadena vacía y devuelva cero filas.
-function toParams({ page, perPage, status, search, category }: ProductFilters) {
+// `page` y `perPage` son opcionales acá: `/products/counts` no pagina, y
+// mandarle una página sería decirle que cuente una porción (TESIS-163).
+function toParams({ page, perPage, status, search, category }: Partial) {
return {
- page,
- per_page: perPage,
+ ...(page === undefined ? {} : { page }),
+ ...(perPage === undefined ? {} : { per_page: perPage }),
...(status === undefined ? {} : { status }),
...(search === undefined || search === '' ? {} : { search }),
...(category === undefined ? {} : { category }),
@@ -152,17 +188,21 @@ export async function fetchProductPage(filters: ProductFilters): Promise {
- const { data } = await client.get>('/products', {
- params: toParams({ page: 1, perPage: 1, status, search }),
+export async function fetchProductCounts(
+ filters: Pick,
+): Promise {
+ const { data } = await client.get<{ data: CatalogCounts }>('/products/counts', {
+ params: toParams({ search: filters.search, category: filters.category }),
})
- return data.meta.total
+ return data.data
}
export async function fetchProduct(id: number): Promise {
diff --git a/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx b/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx
index de7d145..5fa838b 100644
--- a/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx
+++ b/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx
@@ -12,18 +12,31 @@ function product(overrides: Partial = {}): Product {
sku: 'CAB-6-305',
name: 'Cable UTP Cat6',
description: 'Rollo de 305 metros',
+ category: null,
+ packaging: null,
+ technicalStandard: null,
weight: 12.4,
dimensions: '45x30x20',
- category: null,
+ committed: 0,
+ onHand: 10,
+ availableToPromise: 10,
+ inTransit: 0,
totalStock: 10,
stockStatus: 'low',
inTransitQuantity: 0,
inTransitByWarehouse: [],
+ committedByWarehouse: [],
stocks: [
{
warehouseId: 1,
quantity: 10,
- warehouse: { id: 1, name: 'CD Ezeiza', address: 'Autopista Riccheri km 33' },
+ committed: 0,
+ warehouse: {
+ id: 1,
+ name: 'CD Ezeiza',
+ address: 'Autopista Riccheri km 33',
+ capacity: null,
+ },
stockStatus: 'low',
},
],
@@ -51,6 +64,47 @@ function renderModal(initial: Product) {
}
}
+// TESIS-163: «Editar stock» abría el formulario entero del producto. El modal
+// mantiene un solo guardado y cambia de alcance.
+describe('EditProductModal · scope', () => {
+ function renderStockScope() {
+ renderWithTheme(
+ ,
+ )
+ }
+
+ it('edits only the quantities when it is opened from «Editar stock»', () => {
+ renderStockScope()
+
+ expect(screen.getByText('Asignación de stock')).toBeInTheDocument()
+ // El nombre del producto es lo que el formulario entero deja editar: con
+ // alcance de stock no está, y el valor tampoco aparece en ningún input.
+ expect(screen.queryByDisplayValue('Cable UTP Cat6')).not.toBeInTheDocument()
+ expect(screen.queryByText('Información básica')).not.toBeInTheDocument()
+ expect(screen.queryByText('Especificaciones técnicas')).not.toBeInTheDocument()
+ })
+
+ it('says in its title that it is editing stock', () => {
+ renderStockScope()
+
+ expect(screen.getByText('Editar stock: Cable UTP Cat6')).toBeInTheDocument()
+ })
+
+ it('still edits everything with the default scope', () => {
+ renderModal(product())
+
+ expect(screen.getByDisplayValue('Cable UTP Cat6')).toBeInTheDocument()
+ expect(screen.getByText('Información básica')).toBeInTheDocument()
+ })
+})
+
describe('EditProductModal', () => {
it('fills the form with the values of the product it receives', () => {
renderModal(product())
diff --git a/src/features/inventory/components/EditProductModal/EditProductModal.tsx b/src/features/inventory/components/EditProductModal/EditProductModal.tsx
index 5bd68a2..9aa1700 100644
--- a/src/features/inventory/components/EditProductModal/EditProductModal.tsx
+++ b/src/features/inventory/components/EditProductModal/EditProductModal.tsx
@@ -60,6 +60,13 @@ function buildDefaults(product: Product): EditProductFormData {
/**
* Modal de edición de un producto: datos básicos, medidas y stock por depósito.
*
+ * Con `scope="stock"` muestra sólo el bloque de depósitos. El botón «Editar
+ * stock» del detalle abría el formulario entero, que es lo que marcó la
+ * auditoría del 04/10: prometía una cosa y hacía otra. Es un alcance del mismo
+ * modal y no otro componente porque el guardado, el `If-Match` y el 412
+ * (TESIS-101) son los mismos y duplicarlos sería tener dos versiones de la
+ * parte delicada.
+ *
* Es presentacional: recibe el producto y los depósitos ya resueltos y entrega
* en `onSubmit` el cuerpo listo para `PUT /api/v1/products/:id`. Quien lo monta
* decide de dónde salen los datos y qué hacer con el resultado.
@@ -73,7 +80,9 @@ export function EditProductModal({
onClose,
submitting = false,
conflict,
+ scope = 'product',
}: EditProductModalProps) {
+ const onlyStock = scope === 'stock'
const [menuAnchor, setMenuAnchor] = useState(null)
const {
@@ -125,7 +134,7 @@ export function EditProductModal({
return (
)}
-
-
-
-
-
-
-
-
-
-
+ {/* Con alcance `stock` el formulario es sólo el bloque de depósitos:
+ el botón que lo abre dice «Editar stock» y eso es lo que edita. */}
+ {onlyStock ? null : (
+
+
+
+
+
+
- (
-
- )}
- />
-
-
+
+
+
-
-
-
-
- (
+
+ )}
/>
-
+
+
+ )}
-
-
-
+ {onlyStock ? null : (
+
+
+
+
+
+
-
-
-
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ )}
(
- {formatUnits(product.totalStock)}
+ {formatInteger(product.totalStock)}
{product.inTransitQuantity > 0 ? (
{cells.inTransit(product.inTransitQuantity)}
diff --git a/src/features/inventory/components/MasterStockCard/MasterStockCard.tsx b/src/features/inventory/components/MasterStockCard/MasterStockCard.tsx
index 36f0a69..5381beb 100644
--- a/src/features/inventory/components/MasterStockCard/MasterStockCard.tsx
+++ b/src/features/inventory/components/MasterStockCard/MasterStockCard.tsx
@@ -30,7 +30,6 @@ export function MasterStockCard({
totalLabel,
caption,
buckets,
- footnote,
onEditStock,
}: MasterStockCardProps) {
return (
@@ -75,12 +74,6 @@ export function MasterStockCard({
})}
- {footnote === undefined ? null : (
-
- {footnote}
-
- )}
-
)
diff --git a/src/features/inventory/components/WarehouseDistributionCard/WarehouseDistributionCard.types.ts b/src/features/inventory/components/WarehouseDistributionCard/WarehouseDistributionCard.types.ts
index 86304cc..985e87d 100644
--- a/src/features/inventory/components/WarehouseDistributionCard/WarehouseDistributionCard.types.ts
+++ b/src/features/inventory/components/WarehouseDistributionCard/WarehouseDistributionCard.types.ts
@@ -19,6 +19,4 @@ export interface WarehouseDistributionRow {
export interface WarehouseDistributionCardProps {
rows: WarehouseDistributionRow[]
- /** Aclaración al pie sobre las columnas que la API todavía no expone. */
- footnote?: string
}
diff --git a/src/features/inventory/content.ts b/src/features/inventory/content.ts
index 9a65745..6b43c2f 100644
--- a/src/features/inventory/content.ts
+++ b/src/features/inventory/content.ts
@@ -2,6 +2,8 @@
// Mismo criterio que `features/design-system/content.ts`: si más adelante entra
// i18n, este módulo es el único punto a migrar a claves de traducción (ADR-007).
+import { formatInteger } from 'shared/utils'
+
export const inventoryCopy = {
page: {
title: 'Inventario',
@@ -38,7 +40,7 @@ export const inventoryCopy = {
noCategory: 'Sin categoría',
/** Un producto sin unidades en ningún depósito no tiene nodo que mostrar. */
noWarehouse: 'Sin asignar',
- inTransit: (units: number) => `+${formatUnits(units)} en tránsito`,
+ inTransit: (units: number) => `+${formatInteger(units)} en tránsito`,
/** "en 3 depósitos" cuando hay más de uno además del principal. */
moreWarehouses: (count: number) => `en ${count} depósitos`,
},
@@ -65,7 +67,7 @@ export const inventoryCopy = {
next: 'Página siguiente',
page: (page: number) => `Ir a la página ${page}`,
summary: (from: number, to: number, total: number) =>
- `Mostrando ${from} a ${to} de ${formatUnits(total)} ${total === 1 ? 'producto' : 'productos'}`,
+ `Mostrando ${from} a ${to} de ${formatInteger(total)} ${total === 1 ? 'producto' : 'productos'}`,
},
detail: {
breadcrumb: {
@@ -99,7 +101,6 @@ export const inventoryCopy = {
`${length} × ${width} × ${height} cm`,
/** Marca de "sin dato" del DS, la misma que usa el dashboard. */
unknown: '—',
- pendingBackend: 'Empaque y norma técnica todavía no existen en la API.',
},
master: {
title: 'Stock maestro',
@@ -112,12 +113,6 @@ export const inventoryCopy = {
inTransit: 'En tránsito',
availableToPromise: 'Disponible para prometer',
},
- // Cubre las dos piezas del diseño que la API no puede alimentar: la barra
- // de capacidad ("71 % del umbral máximo de 6.000 unidades"), que necesita
- // un techo por producto, y el desglose por reserva. Y aclara que el en
- // tránsito, a diferencia del diseño, no es una porción del total.
- pending:
- 'El en tránsito no forma parte del total. La capacidad y las reservas esperan datos que la API todavía no expone.',
edit: 'Editar stock',
},
distribution: {
@@ -138,8 +133,15 @@ export const inventoryCopy = {
? '1 depósito con stock o en camino'
: `${count} depósitos con stock o en camino`,
empty: 'Este producto no tiene stock asignado en ningún depósito.',
- pending:
- 'En tránsito son las unidades que vienen hacia cada depósito. Comprometido espera que la API modele las reservas.',
+ // En tránsito **por depósito** llega con TESIS-144; el total del producto
+ // ya se muestra en el encabezado. Comprometido sí está, por depósito.
+ pendingInTransit: 'El en tránsito por depósito llega con el detalle de stock por nodo.',
+ },
+ status: {
+ available: 'Disponible',
+ low: 'Stock bajo',
+ critical: 'Crítico',
+ out: 'Sin stock',
},
notFound: 'No encontramos el producto que buscabas.',
backToCatalog: 'Volver al catálogo',
@@ -147,6 +149,8 @@ export const inventoryCopy = {
modal: {
/** El título lleva el nombre del producto; el subtítulo, el SKU. */
title: (productName: string) => `Editar producto: ${productName}`,
+ /** Con alcance `stock`: el modal muestra sólo las cantidades por depósito. */
+ stockTitle: (productName: string) => `Editar stock: ${productName}`,
subtitle: (sku: string) => `Actualizá especificaciones y stock del SKU: ${sku}`,
close: 'Cerrar',
conflict: {
@@ -277,8 +281,3 @@ export const inventoryCopy = {
duplicateWarehouse: 'No repitas el mismo depósito en dos filas',
},
} as const
-
-/** Miles con punto, como el resto de los números de la pantalla. */
-export function formatUnits(value: number): string {
- return new Intl.NumberFormat('es-AR').format(value)
-}
diff --git a/src/features/inventory/hooks/useInventory.ts b/src/features/inventory/hooks/useInventory.ts
index 0a874e7..0af513f 100644
--- a/src/features/inventory/hooks/useInventory.ts
+++ b/src/features/inventory/hooks/useInventory.ts
@@ -1,10 +1,4 @@
-import {
- keepPreviousData,
- useMutation,
- useQueries,
- useQuery,
- useQueryClient,
-} from '@tanstack/react-query'
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { ApiRequestError } from 'shared/api/types'
import {
@@ -12,15 +6,17 @@ import {
fetchCategories,
fetchProduct,
deleteProduct,
- fetchProductCount,
+ fetchProductCounts,
fetchProductPage,
fetchWarehouses,
updateProduct,
} from '../api'
import { inventoryKeys } from '../queryKeys'
import type {
+ CatalogCounts,
CreateProductPayload,
Product,
+ ProductCategory,
ProductFilters,
ProductPage,
StockStatus,
@@ -58,21 +54,23 @@ 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`.
+ * Los cuatro contadores de las pestañas, en una sola consulta.
+ *
+ * Eran cuatro requests, uno por pestaña, cada uno pidiendo una fila sólo para
+ * leer su `meta.total`. La auditoría del 04/10 marcó que la pantalla disparaba
+ * seis llamadas; con `GET /products/counts` (TESIS-162) bajan a tres.
+ *
+ * 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)».
*
- * 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)».
+ * Que falle no voltea la pantalla: la tabla se ve igual y las pestañas quedan
+ * sin número, que es lo que ya hacían cuando un contador fallaba.
*/
-export function useProductCounts(search: string) {
- return useQueries({
- queries: CATALOG_TABS.map(({ status }) => ({
- queryKey: inventoryKeys.count(status, search),
- queryFn: () => fetchProductCount(status, search),
- })),
- // Un contador que falla no puede voltear la pantalla: la tabla se ve igual
- // y la pestaña queda sin número.
- combine: (results) => results.map((result) => result.data),
+export function useProductCounts(search: string, category?: ProductCategory) {
+ return useQuery({
+ queryKey: inventoryKeys.counts(search, category ?? ''),
+ queryFn: () => fetchProductCounts({ search, category }),
})
}
diff --git a/src/features/inventory/pages/InventoryPage.tsx b/src/features/inventory/pages/InventoryPage.tsx
index 0f87a35..358a012 100644
--- a/src/features/inventory/pages/InventoryPage.tsx
+++ b/src/features/inventory/pages/InventoryPage.tsx
@@ -7,11 +7,12 @@ import { ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components'
import type { DataTableTab } from 'shared/components'
import { useDebouncedValue } from 'shared/hooks/useDebouncedValue'
import { notify } from 'shared/store'
+import { formatInteger } from 'shared/utils'
import { CreateProductModal } from '../components/CreateProductModal'
import { DeleteProductDialog } from '../components/DeleteProductDialog'
import { InventoryTable } from '../components/InventoryTable'
-import { formatUnits, inventoryCopy } from '../content'
+import { inventoryCopy } from '../content'
import {
CATALOG_TABS,
RESTRICTED_STATUS,
@@ -158,12 +159,12 @@ export function InventoryPage() {
const from = total === 0 ? 0 : (pageNumber - 1) * PER_PAGE + 1
const to = Math.min(pageNumber * PER_PAGE, total)
- const tabs: DataTableTab[] = CATALOG_TABS.map(({ id }, index) => ({
+ const tabs: DataTableTab[] = CATALOG_TABS.map(({ id }) => ({
id,
label: TAB_LABELS[id],
// Un contador que todavía no resolvió no muestra cero: mostraría un número
// falso durante el primer render y luego saltaría al real.
- count: counts[index] === undefined ? undefined : formatUnits(counts[index]),
+ count: counts.data === undefined ? undefined : formatInteger(counts.data[id]),
}))
return (
diff --git a/src/features/inventory/pages/ProductDetailPage.test.tsx b/src/features/inventory/pages/ProductDetailPage.test.tsx
index 1d92b23..8808df1 100644
--- a/src/features/inventory/pages/ProductDetailPage.test.tsx
+++ b/src/features/inventory/pages/ProductDetailPage.test.tsx
@@ -23,13 +23,20 @@ const PRODUCT: Product = {
name: 'Cable UTP Cat6 100m',
description: null,
category: null,
+ packaging: null,
+ technicalStandard: null,
weight: 4.5,
dimensions: null,
+ committed: 0,
+ onHand: 0,
+ availableToPromise: 0,
+ inTransit: 0,
stocks: [],
totalStock: 0,
stockStatus: 'out_of_stock',
inTransitQuantity: 0,
inTransitByWarehouse: [],
+ committedByWarehouse: [],
updatedAt: '2026-09-01T10:00:00Z',
version: 'W/"1"',
}
@@ -46,17 +53,29 @@ const SEEDED_MOUSE: Product = {
{
warehouseId: 1,
quantity: 100,
- warehouse: { id: 1, name: 'Depósito Central', address: 'Av. 7 N° 1234' },
+ committed: 0,
+ warehouse: { id: 1, name: 'Depósito Central', address: 'Av. 7 N° 1234', capacity: null },
stockStatus: 'low',
},
{
warehouseId: 2,
quantity: 30,
- warehouse: { id: 2, name: 'Depósito Satélite Norte', address: 'Calle 25 N° 456' },
+ committed: 0,
+ warehouse: {
+ id: 2,
+ name: 'Depósito Satélite Norte',
+ address: 'Calle 25 N° 456',
+ capacity: null,
+ },
stockStatus: 'low',
},
],
totalStock: 130,
+ // 130 libres y 14 vendidos sin despachar: 144 en el estante. El en tránsito
+ // queda afuera, que no está en ningún depósito todavía.
+ committed: 14,
+ onHand: 144,
+ availableToPromise: 130,
stockStatus: 'available',
inTransitQuantity: 12,
inTransitByWarehouse: [
@@ -191,8 +210,19 @@ describe('ProductDetailPage', () => {
expect(screen.getByText('Categoría').parentElement).toHaveTextContent('—')
})
+ // El titular es lo que hay en el estante —lo libre más lo vendido sin
+ // despachar—, que es justo lo que las cubetas descomponen. Encabezar con
+ // `totalStock` dejaba la tarjeta sin cerrar: la cubeta de comprometido
+ // sumaba unidades que el titular no contaba.
+ it('headlines what is on the shelf, and not just what is free', () => {
+ showProduct(SEEDED_MOUSE)
+ renderDetail()
+
+ expect(screen.getByText('144')).toBeInTheDocument()
+ })
+
it('takes the master total from the API instead of adding the rows', () => {
- showProduct({ ...SEEDED_MOUSE, totalStock: 131 })
+ showProduct({ ...SEEDED_MOUSE, onHand: 131 })
renderDetail()
expect(screen.getByText('131')).toBeInTheDocument()
@@ -226,3 +256,18 @@ describe('ProductDetailPage', () => {
})
})
})
+
+// El modal se cierra con una animación: si el alcance volviera a `product` al
+// apretar cerrar, el de stock mostraría el formulario entero durante la salida.
+describe('ProductDetailPage · closing the stock editor', () => {
+ it('does not flash the full form on the way out', async () => {
+ showProduct(SEEDED_MOUSE)
+ renderDetail()
+ fireEvent.click(screen.getByRole('button', { name: /Editar stock/ }))
+
+ fireEvent.click(screen.getByRole('button', { name: /Cerrar/ }))
+
+ // Mientras el modal se va, lo que se ve sigue siendo el alcance de stock.
+ expect(screen.queryByRole('textbox', { name: /Nombre/ })).not.toBeInTheDocument()
+ })
+})
diff --git a/src/features/inventory/pages/ProductDetailPage.tsx b/src/features/inventory/pages/ProductDetailPage.tsx
index 9543d1b..ac7e41a 100644
--- a/src/features/inventory/pages/ProductDetailPage.tsx
+++ b/src/features/inventory/pages/ProductDetailPage.tsx
@@ -6,8 +6,10 @@ import { useEffect, useState } from 'react'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import { ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components'
import { notify, useTenantFeature } from 'shared/store'
+import { formatDecimal, formatInteger } from 'shared/utils'
import { EditProductModal } from '../components/EditProductModal'
+import type { EditScope } from '../components/EditProductModal'
import { MasterStockCard } from '../components/MasterStockCard'
import type { StockBucket } from '../components/MasterStockCard'
import { ProductDetailHeader } from '../components/ProductDetailHeader'
@@ -27,12 +29,12 @@ import {
import type { Product, UpdateProductPayload } from '../types'
import { describeConflict } from '../utils/conflict'
import { parseDimensions } from '../utils/dimensions'
-import { formatSpecTimestamp, formatUnits, formatWeight } from '../utils/format'
+import { formatSpecTimestamp } from '../utils/format'
import { distributionPositions } from '../utils/stock'
import { stockLabel, stockRowTone, stockVariant } from '../utils/stockStatus'
const { detail, page } = inventoryCopy
-const { specs: specsCopy, master: masterCopy, distribution: distributionCopy } = detail
+const { specs: specsCopy, master: masterCopy } = detail
// Ruta del catálogo. Las rutas se registran en `app/router/routes.tsx`, capa que
// una feature no puede importar (ver architecture.md §3.2), así que el destino
@@ -56,7 +58,10 @@ function buildSpecs(product: Product): ProductSpec[] {
const hasDimensions = product.dimensions !== null && product.dimensions !== ''
return [
- // La categoría es opcional: los productos anteriores a TESIS-102 no tienen.
+ // Los tres salen de la API: categoría de TESIS-150, empaque y norma de
+ // TESIS-162. Un producto puede no tenerlos cargados, y ahí sí va el «—»: la
+ // diferencia es que ahora es un dato que falta y no una columna que no
+ // existe.
{
id: 'category',
label: specsCopy.fields.category,
@@ -66,7 +71,7 @@ function buildSpecs(product: Product): ProductSpec[] {
{
id: 'weight',
label: specsCopy.fields.weight,
- value: specsCopy.weightValue(formatWeight(product.weight)),
+ value: specsCopy.weightValue(formatDecimal(product.weight)),
mono: true,
},
{
@@ -74,18 +79,20 @@ function buildSpecs(product: Product): ProductSpec[] {
label: specsCopy.fields.dimensions,
value: hasDimensions
? specsCopy.dimensionsValue(
- formatUnits(dimensions.length),
- formatUnits(dimensions.width),
- formatUnits(dimensions.height),
+ formatInteger(dimensions.length),
+ formatInteger(dimensions.width),
+ formatInteger(dimensions.height),
)
: specsCopy.unknown,
mono: true,
unknown: !hasDimensions,
},
- // Ni empaque ni norma técnica existen en el modelo. Se muestran los rótulos
- // del diseño sin valor, en vez de omitir las celdas: así la ficha dice qué
- // falta en lugar de disimularlo.
- { id: 'packaging', label: specsCopy.fields.packaging, value: specsCopy.unknown, unknown: true },
+ {
+ id: 'packaging',
+ label: specsCopy.fields.packaging,
+ value: product.packaging ?? specsCopy.unknown,
+ unknown: product.packaging === null,
+ },
]
}
@@ -94,7 +101,12 @@ function buildSecondarySpecs(product: Product): ProductSpec[] {
const updatedAt = formatSpecTimestamp(product.updatedAt)
return [
- { id: 'standard', label: specsCopy.fields.standard, value: specsCopy.unknown, unknown: true },
+ {
+ id: 'standard',
+ label: specsCopy.fields.standard,
+ value: product.technicalStandard ?? specsCopy.unknown,
+ unknown: product.technicalStandard === null,
+ },
{
id: 'updatedAt',
label: specsCopy.fields.updatedAt,
@@ -105,34 +117,37 @@ function buildSecondarySpecs(product: Product): ProductSpec[] {
}
/**
- * Desglose del stock maestro.
+ * Desglose por estado de reserva, con los números que calcula la API desde
+ * TESIS-162.
+ *
+ * Las tres cubetas mostraban «—» con la aclaración de que el modelo no las
+ * registraba. Ahora muestran dato, y **cero cuando el valor es cero**: un
+ * producto que nadie reservó tiene 0 comprometido, que es un hecho y no una
+ * carencia. Ninguna se deriva acá: `onHand` no es la suma de `stocks[]`, porque
+ * lo vendido sin despachar ya salió de esas filas y sigue en el estante.
*
- * Comprometido y disponible para prometer van sin dato: la API no modela
- * reservas. El en tránsito sí existe, pero **no es una porción del total**:
- * son unidades que salieron de un depósito y no llegaron a otro, y el backend
- * las deja fuera de `total_stock`. Ver el encabezado de `utils/stock.ts`.
+ * El en tránsito sigue **fuera** del total: son unidades que salieron de un
+ * depósito y no llegaron a otro, y el backend las deja fuera de `total_stock`.
*/
function buildBuckets(product: Product): StockBucket[] {
return [
{
id: 'committed',
label: masterCopy.buckets.committed,
- icon: ,
- value: specsCopy.unknown,
- unknown: true,
+ icon: ,
+ value: formatInteger(product.committed),
},
{
id: 'inTransit',
label: masterCopy.buckets.inTransit,
icon: ,
- value: formatUnits(product.inTransitQuantity),
+ value: formatInteger(product.inTransitQuantity),
},
{
id: 'availableToPromise',
label: masterCopy.buckets.availableToPromise,
- icon: ,
- value: specsCopy.unknown,
- unknown: true,
+ icon: ,
+ value: formatInteger(product.availableToPromise),
accent: true,
},
]
@@ -147,9 +162,11 @@ function buildRows(product: Product): WarehouseDistributionRow[] {
id: position.warehouseId,
name: position.name,
location: position.location ?? specsCopy.unknown,
- committed: specsCopy.unknown,
- inTransit: formatUnits(position.incoming),
- onHand: formatUnits(position.quantity),
+ committed: formatInteger(position.committed),
+ inTransit: formatInteger(position.incoming),
+ // Lo físico del depósito, no lo libre: es la misma definición que el
+ // encabezado, y por eso las filas suman el titular (TESIS-162).
+ onHand: formatInteger(position.onHand),
statusLabel: stockLabel(position.stockStatus),
statusVariant: stockVariant(position.stockStatus),
critical: stockRowTone(position.stockStatus) === 'critical',
@@ -160,9 +177,11 @@ function buildRows(product: Product): WarehouseDistributionRow[] {
* Detalle de producto (S12) — especificaciones, stock agregado y distribución
* por depósito del SKU.
*
- * Los datos salen de `GET /api/v1/products/:id`, que es lo único que hay: los
- * campos del diseño que la API todavía no expone se muestran sin dato en lugar
- * de rellenarse con un valor plausible.
+ * Todo sale de `GET /api/v1/products/:id`. Desde TESIS-162 y TESIS-144 la API
+ * expone también el comprometido por depósito, el en tránsito por depósito, el
+ * empaque y la norma técnica, así que ya no queda ninguna pieza del diseño
+ * mostrándose sin dato por falta de modelo: un «—» ahora es un dato que el
+ * producto no tiene cargado, no una columna que no existe.
*/
export function ProductDetailPage() {
const { productId } = useParams()
@@ -176,7 +195,20 @@ export function ProductDetailPage() {
const { pathname, state } = useLocation()
const navigate = useNavigate()
const abrirEdicion = typeof state === 'object' && state !== null && 'edit' in state
+ // Abierto o cerrado, y con qué alcance, en dos estados separados: «Editar
+ // producto» abre el formulario entero y «Editar stock» sólo las cantidades.
+ //
+ // Separados y no un `EditScope | null` porque el modal se cierra con una
+ // animación: al pasar el alcance a `null` y leerlo con un `?? 'product'`, el
+ // de stock mostraba el formulario completo durante la salida. El alcance sólo
+ // cambia al abrir, así que lo que se ve mientras se cierra es lo que había.
const [editing, setEditing] = useState(abrirEdicion)
+ const [scope, setScope] = useState('product')
+
+ function openEditor(next: EditScope) {
+ setScope(next)
+ setEditing(true)
+ }
const integrationsEnabled = useTenantFeature('integrations')
// La intención se consume una sola vez. `location.state` vive en
@@ -267,27 +299,30 @@ export function ProductDetailPage() {
statusLabel={stockLabel(product.data.stockStatus)}
statusVariant={stockVariant(product.data.stockStatus)}
catalogPath={CATALOG_PATH}
- onEdit={() => setEditing(true)}
+ onEdit={() => openEditor('product')}
/>
+ {/* El titular es `onHand` y no `totalStock`: las cubetas lo
+ descomponen —comprometido + disponible para prometer— y
+ `totalStock` es sólo la segunda, así que encabezar con él dejaba
+ la tarjeta sin cerrar. El en tránsito queda afuera a propósito:
+ esas unidades no están en ningún depósito todavía.
+ El epígrafe cuenta las posiciones y no `stocks`, porque un
+ depósito puede quedarse sin fila de stock y seguir teniendo
+ unidades vendidas sin despachar. */}
setEditing(true)}
- />
- openEditor('stock')}
/>
+
{/* Canales de venta: sólo para las empresas con la feature encendida,
@@ -308,6 +343,7 @@ export function ProductDetailPage() {
[...inventoryKeys.all, 'products'] as const,
productList: (filters: ProductFilters) => [...inventoryKeys.products(), 'list', filters] as const,
- 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,
+ // Una sola clave para los cuatro contadores: desde TESIS-162 vienen en una
+ // respuesta, así que tampoco hay cuatro cachés que puedan quedar desparejas.
+ counts: (search: string, category: string) =>
+ [...inventoryKeys.products(), 'counts', search, category] 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/types.ts b/src/features/inventory/types.ts
index 573926b..e687061 100644
--- a/src/features/inventory/types.ts
+++ b/src/features/inventory/types.ts
@@ -66,12 +66,20 @@ export interface Warehouse {
id: number
name: string
address: string
+ /**
+ * Capacidad declarada, en unidades (TESIS-162). `null` cuando nadie la cargó
+ * todavía: no es cero, que diría que no entra nada, y por eso la barra de
+ * ocupación no se dibuja en ese caso.
+ */
+ capacity: number | null
}
/** Cantidad de un producto en un depósito concreto (tabla `stocks`). */
export interface ProductStock {
warehouseId: number
quantity: number
+ /** Vendido y todavía en este depósito (TESIS-162). */
+ committed: number
warehouse: Warehouse
/**
* Disponibilidad de este depósito. La calcula el backend con la misma regla
@@ -102,10 +110,30 @@ export interface Product {
sku: string
name: string
description: string | null
+ category: ProductCategory | null
+ /** Cómo viene embalado. Texto libre: lo describe el rubro, no un vocabulario. */
+ packaging: string | null
+ /** Norma del producto (IRAM, IEC). Es un código de un organismo externo. */
+ technicalStandard: string | null
weight: number
dimensions: string | null
- /** Opcional: los productos anteriores a TESIS-102 no tienen ninguna. */
- category: ProductCategory | null
+ /**
+ * Los tres números del stock, que la API calcula desde TESIS-162. Ninguno se
+ * deriva acá: `onHand` no es la suma de `stocks[].quantity` —lo vendido sin
+ * despachar sigue en el estante pero ya salió de esas filas— y restarlos mal
+ * del lado del cliente era justamente el problema.
+ *
+ * Son números y no `null`: un producto que nadie reservó tiene 0
+ * comprometido, que es un dato.
+ */
+ committed: number
+ onHand: number
+ availableToPromise: number
+ /**
+ * El mismo `inTransitQuantity`, con el nombre que usa el detalle. Son el
+ * mismo campo de la API (`in_transit_quantity`) y conviene unificarlos.
+ */
+ inTransit: number
stocks: ProductStock[]
/** Unidades en depósito sumando todos los depósitos, calculado por la API. */
totalStock: number
@@ -119,6 +147,12 @@ export interface Product {
* se recibe. La suma de `quantity` es `inTransitQuantity`.
*/
inTransitByWarehouse: IncomingTransit[]
+ /**
+ * Lo vendido sin despachar, por depósito. Puede nombrar depósitos que no
+ * están en `stocks`: si la venta se llevó la última unidad, la fila de stock
+ * queda en cero o desaparece y las unidades siguen en el estante.
+ */
+ committedByWarehouse: IncomingTransit[]
updatedAt: string
/**
* Versión del agregado que devolvió la API en el header `ETag` (TESIS-101).
@@ -197,3 +231,14 @@ export interface LinkProductResult {
mapping: ProductMapping
warnings: string[]
}
+
+/**
+ * Cuántos productos tiene cada pestaña del catálogo (`GET /products/counts`).
+ * Las claves son las de `CATALOG_TABS`: el backend las nombra igual.
+ */
+export interface CatalogCounts {
+ all: number
+ available: number
+ low: number
+ out_of_stock: number
+}
diff --git a/src/features/inventory/utils/conflict.test.ts b/src/features/inventory/utils/conflict.test.ts
index 4b485d9..cf57874 100644
--- a/src/features/inventory/utils/conflict.test.ts
+++ b/src/features/inventory/utils/conflict.test.ts
@@ -17,7 +17,8 @@ function stock(warehouseId: number, quantity: number, warehouseName: string): Pr
return {
warehouseId,
quantity,
- warehouse: { id: warehouseId, name: warehouseName, address: 'Calle 1' },
+ committed: 0,
+ warehouse: { id: warehouseId, name: warehouseName, address: 'Calle 1', capacity: null },
stockStatus: 'low',
}
}
@@ -28,13 +29,20 @@ function product(overrides: Partial = {}): Product {
sku: 'CAB-6-305',
name: 'Cable UTP Cat6',
description: 'Rollo de 305 metros',
+ category: null,
+ packaging: null,
+ technicalStandard: null,
weight: 12.4,
dimensions: '45x30x20',
- category: null,
+ committed: 0,
+ onHand: 10,
+ availableToPromise: 10,
+ inTransit: 0,
totalStock: 10,
stockStatus: 'low',
inTransitQuantity: 0,
inTransitByWarehouse: [],
+ committedByWarehouse: [],
stocks: [stock(1, 10, 'CD Ezeiza')],
updatedAt: '2026-08-30T12:00:00.000Z',
version: '"abc"',
diff --git a/src/features/inventory/utils/format.test.ts b/src/features/inventory/utils/format.test.ts
index 2276ec3..47ae695 100644
--- a/src/features/inventory/utils/format.test.ts
+++ b/src/features/inventory/utils/format.test.ts
@@ -1,27 +1,11 @@
import { formatDate } from 'shared/utils'
import { describe, expect, it } from 'vitest'
-import { formatSpecTimestamp, formatUnits, formatWeight } from './format'
+import { formatSpecTimestamp } from './format'
-describe('formatUnits', () => {
- it('uses a thousands separator, as the design writes it', () => {
- expect(formatUnits(4280)).toBe('4.280')
- })
-
- it('has no separator below one thousand', () => {
- expect(formatUnits(280)).toBe('280')
- })
-})
-
-describe('formatWeight', () => {
- it('formats with a comma decimal separator', () => {
- expect(formatWeight(1.45)).toBe('1,45')
- })
-
- it('pads to two decimals', () => {
- expect(formatWeight(2)).toBe('2,00')
- })
-})
+// Los ejemplos de `formatUnits` y `formatWeight` viven en
+// `shared/utils/number.test.ts` desde TESIS-166: eran el formateador
+// compartido, no una regla de esta feature.
describe('formatSpecTimestamp', () => {
it('returns null for an invalid date', () => {
diff --git a/src/features/inventory/utils/format.ts b/src/features/inventory/utils/format.ts
index 5edae08..c94417c 100644
--- a/src/features/inventory/utils/format.ts
+++ b/src/features/inventory/utils/format.ts
@@ -7,23 +7,6 @@
import { formatDate } from 'shared/utils'
-const integerFormatter = new Intl.NumberFormat('es-AR', { maximumFractionDigits: 0 })
-
-const decimalFormatter = new Intl.NumberFormat('es-AR', {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
-})
-
-/** Cantidad de unidades: `4280` → `"4.280"`. */
-export function formatUnits(value: number): string {
- return integerFormatter.format(value)
-}
-
-/** Peso en kg con dos decimales: `1.45` → `"1,45"`. */
-export function formatWeight(value: number): string {
- return decimalFormatter.format(value)
-}
-
/**
* Marca temporal de la ficha: `"24 ago 2026 · 09:14"`.
*
diff --git a/src/features/inventory/utils/payload.test.ts b/src/features/inventory/utils/payload.test.ts
index a4236a9..2c79d16 100644
--- a/src/features/inventory/utils/payload.test.ts
+++ b/src/features/inventory/utils/payload.test.ts
@@ -46,18 +46,31 @@ function product(overrides: Partial = {}): Product {
sku: 'CAB-6-305',
name: 'Cable UTP Cat6',
description: 'Rollo de 305 metros',
+ category: null,
+ packaging: null,
+ technicalStandard: null,
weight: 12.4,
dimensions: '45x30x20',
- category: null,
+ committed: 0,
+ onHand: 10,
+ availableToPromise: 10,
+ inTransit: 0,
totalStock: 10,
stockStatus: 'low',
inTransitQuantity: 0,
inTransitByWarehouse: [],
+ committedByWarehouse: [],
stocks: [
{
warehouseId: 1,
quantity: 10,
- warehouse: { id: 1, name: 'CD Ezeiza', address: 'Autopista Riccheri km 33' },
+ committed: 0,
+ warehouse: {
+ id: 1,
+ name: 'CD Ezeiza',
+ address: 'Autopista Riccheri km 33',
+ capacity: null,
+ },
stockStatus: 'low',
},
],
@@ -129,7 +142,8 @@ describe('buildUpdatePayload', () => {
{
warehouseId: 2,
quantity: 40,
- warehouse: { id: 2, name: 'CD Córdoba', address: 'Ruta 9 km 695' },
+ committed: 0,
+ warehouse: { id: 2, name: 'CD Córdoba', address: 'Ruta 9 km 695', capacity: null },
stockStatus: 'low',
},
],
diff --git a/src/features/inventory/utils/stock.test.ts b/src/features/inventory/utils/stock.test.ts
index 5e64a59..6305f56 100644
--- a/src/features/inventory/utils/stock.test.ts
+++ b/src/features/inventory/utils/stock.test.ts
@@ -8,7 +8,8 @@ function stock(overrides: Partial = {}): ProductStock {
return {
warehouseId: 1,
quantity: 10,
- warehouse: { id: 1, name: 'CD Ezeiza', address: 'Autopista Riccheri km 33' },
+ committed: 0,
+ warehouse: { id: 1, name: 'CD Ezeiza', address: 'Autopista Riccheri km 33', capacity: null },
stockStatus: 'low',
...overrides,
}
@@ -21,13 +22,20 @@ function product(overrides: Partial = {}): Product {
name: 'Cable UTP Cat6',
description: null,
category: null,
+ packaging: null,
+ technicalStandard: null,
weight: 12.4,
+ committed: 0,
+ onHand: 0,
+ availableToPromise: 0,
+ inTransit: 0,
dimensions: null,
stocks: [],
totalStock: 0,
stockStatus: 'out_of_stock',
inTransitQuantity: 0,
inTransitByWarehouse: [],
+ committedByWarehouse: [],
updatedAt: '2026-08-30T12:00:00.000Z',
version: null,
...overrides,
@@ -37,12 +45,12 @@ function product(overrides: Partial = {}): Product {
const central = stock({
warehouseId: 1,
quantity: 100,
- warehouse: { id: 1, name: 'Central', address: 'Av. 7' },
+ warehouse: { id: 1, name: 'Central', address: 'Av. 7', capacity: null },
})
const satelite = stock({
warehouseId: 2,
quantity: 30,
- warehouse: { id: 2, name: 'Satélite', address: 'Calle 25' },
+ warehouse: { id: 2, name: 'Satélite', address: 'Calle 25', capacity: null },
})
describe('sortByQuantityDesc', () => {
@@ -101,10 +109,93 @@ describe('distributionPositions', () => {
location: null,
quantity: 0,
incoming: 4,
+ committed: 0,
+ onHand: 0,
stockStatus: 'out_of_stock',
})
})
+ // Si la venta se llevó la última unidad, el depósito se queda sin fila de
+ // stock y lo comprometido sigue en su estante. Esconderlo haría desaparecer
+ // de la tabla unidades que están ahí.
+ it('lists a warehouse that only has units sold and not yet dispatched', () => {
+ const positions = distributionPositions(
+ product({
+ stocks: [central],
+ committedByWarehouse: [{ warehouseId: 9, name: 'Depósito Sur', quantity: 6 }],
+ }),
+ )
+
+ expect(positions[1]).toEqual({
+ warehouseId: 9,
+ name: 'Depósito Sur',
+ location: null,
+ quantity: 0,
+ incoming: 0,
+ committed: 6,
+ // Sin fila de stock no quedan libres, pero las seis vendidas están en su
+ // estante: ése es todo su físico.
+ onHand: 6,
+ stockStatus: 'out_of_stock',
+ })
+ })
+
+ // El mismo depósito puede estar recibiendo unidades y tener otras vendidas:
+ // es una fila, no dos.
+ it('keeps a warehouse with both incoming and committed units in a single row', () => {
+ const positions = distributionPositions(
+ product({
+ stocks: [central],
+ inTransitByWarehouse: [{ warehouseId: 9, name: 'Depósito Sur', quantity: 4 }],
+ committedByWarehouse: [{ warehouseId: 9, name: 'Depósito Sur', quantity: 6 }],
+ }),
+ )
+
+ expect(positions).toHaveLength(2)
+ expect(positions[1]).toMatchObject({ warehouseId: 9, incoming: 4, committed: 6 })
+ })
+
+ // Lo comprometido de un depósito que sí tiene fila sale de esa fila, no de
+ // una segunda entrada.
+ it('reads the committed units of a stocked warehouse from its own row', () => {
+ const positions = distributionPositions(
+ product({
+ stocks: [{ ...central, committed: 3 }],
+ committedByWarehouse: [{ warehouseId: 1, name: 'Central', quantity: 3 }],
+ }),
+ )
+
+ expect(positions).toHaveLength(1)
+ expect(positions[0]).toMatchObject({ warehouseId: 1, committed: 3 })
+ })
+
+ // El encabezado de la pantalla dice «En depósito (físico)» y lo define como
+ // libre + comprometido. La columna de la tabla lleva el mismo rótulo, así que
+ // tiene que contar lo mismo: con 15 libres y 4 vendidas sin despachar, en el
+ // estante hay 19, no 15.
+ it('counts the physical units of a warehouse, not just the free ones', () => {
+ const positions = distributionPositions(
+ product({ stocks: [{ ...central, quantity: 15, committed: 4 }] }),
+ )
+
+ expect(positions[0]).toMatchObject({ quantity: 15, committed: 4, onHand: 19 })
+ })
+
+ // El titular de la tarjeta es on hand y las filas tienen que sumarlo: antes
+ // daban 40 contra un encabezado de 44.
+ it('adds its rows up to the on-hand figure of the product', () => {
+ const positions = distributionPositions(
+ product({
+ stocks: [
+ { ...central, quantity: 15, committed: 4 },
+ { ...central, warehouseId: 2, quantity: 25, committed: 0 },
+ ],
+ }),
+ )
+
+ expect(positions.reduce((total, position) => total + position.onHand, 0)).toBe(44)
+ })
+
it('answers no rows for a product with no stock and nothing in flight', () => {
expect(distributionPositions(product())).toEqual([])
})
diff --git a/src/features/inventory/utils/stock.ts b/src/features/inventory/utils/stock.ts
index e0c8ecc..e8f5b4d 100644
--- a/src/features/inventory/utils/stock.ts
+++ b/src/features/inventory/utils/stock.ts
@@ -28,9 +28,21 @@ export interface DistributionPosition {
name: string
/** `null` si el depósito sólo aparece por unidades entrantes (sin fila de stock). */
location: string | null
+ /** Libres: lo que queda en `stocks` después de descontar lo vendido. */
quantity: number
/** Unidades en vuelo hacia este depósito. */
incoming: number
+ /** Vendido y todavía en este depósito, sin despachar (TESIS-162). */
+ committed: number
+ /**
+ * Lo que hay físicamente en el estante: libres más comprometidas.
+ *
+ * Es la misma definición que el encabezado de la pantalla —«En depósito
+ * (físico)» de TESIS-162— y por eso las filas suman el titular. Mostrar acá
+ * sólo las libres hacía que la columna y el encabezado usaran la misma
+ * palabra para dos cosas distintas, y las filas no cerraban: 40 contra 44.
+ */
+ onHand: number
stockStatus: StockStatus
}
@@ -57,29 +69,55 @@ export function sortByQuantityDesc(stocks: ProductStock[]): ProductStock[] {
*/
export function distributionPositions(product: Product): DistributionPosition[] {
const incoming = new Map(
- product.inTransitByWarehouse.map((transit) => [transit.warehouseId, transit]),
+ product.inTransitByWarehouse.map((transit) => [transit.warehouseId, transit.quantity]),
)
-
const stocked = sortByQuantityDesc(product.stocks).map((stock) => ({
warehouseId: stock.warehouseId,
name: stock.warehouse.name,
location: stock.warehouse.address,
quantity: stock.quantity,
- incoming: incoming.get(stock.warehouseId)?.quantity ?? 0,
+ incoming: incoming.get(stock.warehouseId) ?? 0,
+ committed: stock.committed,
+ onHand: stock.quantity + stock.committed,
stockStatus: stock.stockStatus,
}))
+ // Un depósito sin fila de stock entra igual si tiene unidades en camino o
+ // vendidas sin despachar. Esconderlo haría desaparecer de la tabla unidades
+ // que existen: las que viajan hacia él y las que están en su estante
+ // esperando el despacho.
const stockedIds = new Set(product.stocks.map((stock) => stock.warehouseId))
- const onlyIncoming = product.inTransitByWarehouse
- .filter((transit) => !stockedIds.has(transit.warehouseId))
- .map((transit) => ({
- warehouseId: transit.warehouseId,
+ const sinFila = new Map()
+ for (const transit of product.inTransitByWarehouse) {
+ if (stockedIds.has(transit.warehouseId)) continue
+ sinFila.set(transit.warehouseId, {
name: transit.name,
- location: null,
- quantity: 0,
incoming: transit.quantity,
- stockStatus: 'out_of_stock' as const,
- }))
+ committed: 0,
+ })
+ }
+ for (const row of product.committedByWarehouse) {
+ if (stockedIds.has(row.warehouseId)) continue
+ const previo = sinFila.get(row.warehouseId)
+ sinFila.set(row.warehouseId, {
+ name: row.name,
+ incoming: previo?.incoming ?? 0,
+ committed: row.quantity,
+ })
+ }
+
+ const soloEnCamino = [...sinFila].map(([warehouseId, fila]) => ({
+ warehouseId,
+ name: fila.name,
+ location: null,
+ quantity: 0,
+ incoming: fila.incoming,
+ committed: fila.committed,
+ // Sin fila de stock no quedan libres, pero lo vendido sin despachar sigue
+ // en el estante: ése es todo su físico.
+ onHand: fila.committed,
+ stockStatus: 'out_of_stock' as const,
+ }))
- return [...stocked, ...onlyIncoming]
+ return [...stocked, ...soloEnCamino]
}
diff --git a/src/features/orders/api.test.ts b/src/features/orders/api.test.ts
index 4a58c24..350169d 100644
--- a/src/features/orders/api.test.ts
+++ b/src/features/orders/api.test.ts
@@ -9,6 +9,7 @@ import {
dispatchShipment,
fetchCatalogProducts,
fetchOrder,
+ fetchOrderPage,
fetchOrderShipment,
fetchProductStocks,
fetchProvinces,
@@ -149,6 +150,7 @@ const ORDER = {
customer_document: '30-71234567-8',
customer_address: 'Av. Corrientes 3247',
customer_zip_code: 'C1193',
+ requires_shipping: true,
customer_city: null,
customer_province: null,
status: 'paid',
@@ -166,6 +168,7 @@ describe('fetchOrder', () => {
customer_document: '30-71234567-8',
customer_address: 'Av. Corrientes 3247',
customer_zip_code: 'C1193',
+ requires_shipping: true,
status: 'paid',
total_amount: 960000,
created_at: '2026-08-12T12:42:00Z',
@@ -197,6 +200,19 @@ describe('fetchOrder', () => {
])
})
+ // Si el front se despliega antes que el backend que agrega el campo,
+ // `undefined` es falsy y el detalle trataría la orden como retiro en el
+ // local: ocultaría el envío y el despacho de una venta que sí los lleva.
+ it('treats an order whose API does not send requires_shipping as one that ships', async () => {
+ const { requires_shipping: omitido, ...sinElCampo } = ORDER
+ expect(omitido).toBe(true)
+ vi.spyOn(client, 'get').mockResolvedValueOnce(respond({ ...sinElCampo, order_items: [] }))
+
+ const order = await fetchOrder(8829)
+
+ expect(order.requiresShipping).toBe(true)
+ })
+
it('keeps the version from the ETag as it came', async () => {
vi.spyOn(client, 'get').mockResolvedValueOnce(
respond({ ...ORDER, order_items: [] }, { etag: 'W/"abc123"' }),
@@ -221,6 +237,7 @@ describe('updateOrder', () => {
customer_city: 'CABA',
customer_province: 'Ciudad Autónoma de Buenos Aires',
customer_zip_code: '1193',
+ requires_shipping: true,
status: 'paid' as const,
},
}
@@ -387,6 +404,7 @@ describe('the confirmation of a manual order', () => {
customer_city: 'CABA',
customer_province: 'Ciudad Autónoma de Buenos Aires',
customer_zip_code: '1193',
+ requires_shipping: true,
items: [{ product_id: 12, warehouse_id: 3, quantity: 4, unit_price: 120000 }],
},
}
@@ -418,3 +436,20 @@ describe('the confirmation of a manual order', () => {
expect(shipment.trackingNumber).toBe('AND-9920-X8829-Z')
})
})
+
+// El mismo riesgo que en el detalle, en el otro mapeo: el listado marca con un
+// chip las ventas que se retiran en el local, y sin el campo las marcaría
+// todas.
+describe('fetchOrderPage and a backend that does not send requires_shipping', () => {
+ it('treats every order as one that ships', async () => {
+ const { requires_shipping: omitido, ...sinElCampo } = ORDER
+ expect(omitido).toBe(true)
+ vi.spyOn(client, 'get').mockResolvedValueOnce(
+ respond({ data: [sinElCampo], meta: { page: 1, per_page: 20, total: 1 } }),
+ )
+
+ const page = await fetchOrderPage({ page: 1, perPage: 20, search: '' })
+
+ expect(page.orders[0].requiresShipping).toBe(true)
+ })
+})
diff --git a/src/features/orders/api.ts b/src/features/orders/api.ts
index bb34e4b..9513a05 100644
--- a/src/features/orders/api.ts
+++ b/src/features/orders/api.ts
@@ -49,6 +49,7 @@ interface ApiOrderSummary {
customer_address: string | null
customer_zip_code: string | null
status: OrderStatus
+ requires_shipping: boolean
courier: ApiCourier | null
total_amount: number | null
item_count: number
@@ -96,6 +97,7 @@ interface ApiOrderDetail {
customer_city: string | null
customer_province: string | null
status: OrderStatus
+ requires_shipping: boolean
total_amount: number | null
order_items: ApiOrderItem[]
created_at: string
@@ -150,6 +152,10 @@ function toOrder(order: ApiOrderSummary): OrderSummary {
customerAddress: order.customer_address,
customerZipCode: order.customer_zip_code,
status: order.status,
+ // `?? true`: si el front se despliega antes que el backend que agrega el
+ // campo, `undefined` es falsy y el listado marcaría **todas** las órdenes
+ // como retiro en el local.
+ requiresShipping: order.requires_shipping ?? true,
// La columna sólo muestra el nombre, así que el dominio se queda con eso y
// no arrastra el id de la integración hasta la tabla. Si alguna pantalla
// necesita enlazar a la integración, el id está acá para levantarlo.
@@ -195,6 +201,7 @@ function toOrderDetail(data: ApiOrderDetail, version: string | null): OrderDetai
customerCity: data.customer_city,
customerProvince: data.customer_province,
status: data.status,
+ requiresShipping: data.requires_shipping ?? true,
version,
totalAmount: data.total_amount,
lines: data.order_items.map((item) => ({
diff --git a/src/features/orders/components/DestinationFieldsCard/DestinationFieldsCard.tsx b/src/features/orders/components/DestinationFieldsCard/DestinationFieldsCard.tsx
index 8a7e481..f50981c 100644
--- a/src/features/orders/components/DestinationFieldsCard/DestinationFieldsCard.tsx
+++ b/src/features/orders/components/DestinationFieldsCard/DestinationFieldsCard.tsx
@@ -39,9 +39,13 @@ export function DestinationFieldsCard({
provincesLoading,
provincesError,
readOnly = false,
+ optional = false,
}: DestinationFieldsCardProps) {
return (
- } title={copy.title}>
+ }
+ title={optional ? copy.optionalTitle : copy.title}
+ >
{formatCount(line.quantity)},
+ render: (line) => {formatInteger(line.quantity)},
},
{
id: 'subtotal',
diff --git a/src/features/orders/components/OrdersTable/CarrierCell.tsx b/src/features/orders/components/OrdersTable/CarrierCell.tsx
index 21f3b9f..af787ed 100644
--- a/src/features/orders/components/OrdersTable/CarrierCell.tsx
+++ b/src/features/orders/components/OrdersTable/CarrierCell.tsx
@@ -6,6 +6,8 @@ import { carrierInitials } from './carrierInitials'
interface CarrierCellProps {
carrier: string | null
+ /** La orden la retira el cliente: no espera courier (TESIS-162). */
+ pickup?: boolean
}
/**
@@ -15,12 +17,16 @@ interface CarrierCellProps {
* Sin courier no se dibuja el cuadrado. Un recuadro vacío en una columna que
* mayormente está vacía —el courier se asigna recién al confirmar el despacho—
* sería ruido en cada fila, así que queda sólo el texto atenuado.
+ *
+ * Con retiro en el local la celda lo dice en vez de quedar vacía: esa orden no
+ * está esperando un courier, y hasta TESIS-162 se veía igual que una a la que
+ * le falta el despacho.
*/
-export function CarrierCell({ carrier }: CarrierCellProps) {
+export function CarrierCell({ carrier, pickup = false }: CarrierCellProps) {
if (carrier === null) {
return (
- {ordersCopy.cells.noCarrier}
+ {pickup ? ordersCopy.cells.pickup : ordersCopy.cells.noCarrier}
)
}
diff --git a/src/features/orders/components/OrdersTable/OrdersTable.test.tsx b/src/features/orders/components/OrdersTable/OrdersTable.test.tsx
index 37ed87e..f1b22ec 100644
--- a/src/features/orders/components/OrdersTable/OrdersTable.test.tsx
+++ b/src/features/orders/components/OrdersTable/OrdersTable.test.tsx
@@ -14,6 +14,7 @@ function order(overrides: Partial = {}): OrderSummary {
customerAddress: 'Av. Rivadavia 1234',
customerZipCode: '1406',
status: 'paid',
+ requiresShipping: true,
carrier: 'Correo Argentino',
totalAmount: 1478300,
itemCount: 3,
@@ -161,4 +162,12 @@ describe('OrdersTable', () => {
expect(screen.getByText('No hay órdenes que coincidan con el filtro.')).toBeInTheDocument()
})
+
+ // TESIS-162: la fila dice que no espera courier, en vez de verse igual que
+ // una a la que le falta el despacho.
+ it('says a pickup order is picked up at the store', () => {
+ renderTable([order({ carrier: null, requiresShipping: false })])
+
+ expect(screen.getByText('Retiro en el local')).toBeInTheDocument()
+ })
})
diff --git a/src/features/orders/components/OrdersTable/OrdersTable.tsx b/src/features/orders/components/OrdersTable/OrdersTable.tsx
index f4768e4..c54f077 100644
--- a/src/features/orders/components/OrdersTable/OrdersTable.tsx
+++ b/src/features/orders/components/OrdersTable/OrdersTable.tsx
@@ -3,10 +3,11 @@ import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
import { Link, Typography } from '@mui/material'
import { DataTable, StackedCell, StatusBadge } from 'shared/components'
import type { DataTableAction, DataTableColumn } from 'shared/components'
+import { formatMoney } from 'shared/utils'
import { ordersCopy } from '../../content'
import type { OrderSummary } from '../../types'
-import { formatMoney, formatOrderDate, formatOrderId, formatOrderTime } from '../../utils/format'
+import { formatOrderDate, formatOrderId, formatOrderTime } from '../../utils/format'
import { statusLabel, statusRowTone, statusVariant } from '../../utils/status'
import { CarrierCell } from './CarrierCell'
@@ -75,7 +76,7 @@ function buildColumns(onView: (order: OrderSummary) => void): DataTableColumn,
+ render: (order) => ,
},
{
id: 'total',
diff --git a/src/features/orders/components/PaymentSummaryCard/PaymentSummaryCard.tsx b/src/features/orders/components/PaymentSummaryCard/PaymentSummaryCard.tsx
index 4312952..f3c813a 100644
--- a/src/features/orders/components/PaymentSummaryCard/PaymentSummaryCard.tsx
+++ b/src/features/orders/components/PaymentSummaryCard/PaymentSummaryCard.tsx
@@ -19,8 +19,14 @@ const paymentCopy = ordersCopy.detail.payment
* «Resumen de pago» de S08: productos, envío y total. Presentacional — los
* montos llegan ya calculados (`paymentSummary`) y formateados.
*/
-export function PaymentSummaryCard({ subtotal, shipping, total }: PaymentSummaryCardProps) {
+export function PaymentSummaryCard({
+ subtotal,
+ shipping,
+ total,
+ pickup = false,
+}: PaymentSummaryCardProps) {
const titleId = useId()
+ const missingShipping = pickup ? paymentCopy.shippingPickup : paymentCopy.shippingPending
return (
@@ -43,7 +49,7 @@ export function PaymentSummaryCard({ subtotal, shipping, total }: PaymentSummary
{paymentCopy.shipping}
- {shipping ?? paymentCopy.shippingPending}
+ {shipping ?? missingShipping}
diff --git a/src/features/orders/components/PaymentSummaryCard/PaymentSummaryCard.types.ts b/src/features/orders/components/PaymentSummaryCard/PaymentSummaryCard.types.ts
index b92e4f7..9df46c0 100644
--- a/src/features/orders/components/PaymentSummaryCard/PaymentSummaryCard.types.ts
+++ b/src/features/orders/components/PaymentSummaryCard/PaymentSummaryCard.types.ts
@@ -4,4 +4,10 @@ export interface PaymentSummaryCardProps {
/** Null cuando el envío todavía no tiene costo: se muestra como pendiente. */
shipping: string | null
total: string
+ /**
+ * Un retiro en local no lleva envío (TESIS-162), así que su costo no está
+ * «sin cotizar»: no va a cotizarse nunca. La fila dice de qué se trata en vez
+ * de prometer un precio que no va a llegar.
+ */
+ pickup?: boolean
}
diff --git a/src/features/orders/components/ProductPicker/ProductPicker.tsx b/src/features/orders/components/ProductPicker/ProductPicker.tsx
index f1d18e5..8bb98a6 100644
--- a/src/features/orders/components/ProductPicker/ProductPicker.tsx
+++ b/src/features/orders/components/ProductPicker/ProductPicker.tsx
@@ -1,8 +1,9 @@
import AddIcon from '@mui/icons-material/Add'
import { Autocomplete, Button, InputAdornment, TextField, Typography } from '@mui/material'
import { useState } from 'react'
+import { formatInteger } from 'shared/utils'
-import { formatCount, ordersCopy } from '../../content'
+import { ordersCopy } from '../../content'
import type { CatalogProduct } from '../../types'
import { isDraftItemValid } from '../../utils/draft'
@@ -118,7 +119,7 @@ export function ProductPicker({
{addedIds.has(option.id)
? copy.alreadyAdded
- : copy.optionMeta(option.category, formatCount(option.totalStock))}
+ : copy.optionMeta(option.category, formatInteger(option.totalStock))}
diff --git a/src/features/orders/components/QuoteOptionList/QuoteOptionList.tsx b/src/features/orders/components/QuoteOptionList/QuoteOptionList.tsx
index 3e39921..a8b22b0 100644
--- a/src/features/orders/components/QuoteOptionList/QuoteOptionList.tsx
+++ b/src/features/orders/components/QuoteOptionList/QuoteOptionList.tsx
@@ -3,9 +3,9 @@ import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked'
import ScheduleIcon from '@mui/icons-material/Schedule'
import { Stack, Typography } from '@mui/material'
import { StatusBadge } from 'shared/components'
+import { formatMoney } from 'shared/utils'
import { ordersCopy } from '../../content'
-import { formatMoney } from '../../utils/format'
import { SelectableCard } from '../SelectableCard'
import {
diff --git a/src/features/orders/components/RecalcCard.tsx b/src/features/orders/components/RecalcCard.tsx
index 5820631..ba24c87 100644
--- a/src/features/orders/components/RecalcCard.tsx
+++ b/src/features/orders/components/RecalcCard.tsx
@@ -2,9 +2,9 @@ import CalculateOutlinedIcon from '@mui/icons-material/CalculateOutlined'
import { Divider, Stack, Typography } from '@mui/material'
import { StatusBadge } from 'shared/components'
import type { StatusVariant } from 'shared/components'
+import { formatMoney } from 'shared/utils'
import { ordersCopy } from '../content'
-import { formatMoney } from '../utils/format'
import { FormSection } from './FormSection'
diff --git a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.tsx b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.tsx
index ac1ce22..5b5e2ff 100644
--- a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.tsx
+++ b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.tsx
@@ -81,7 +81,11 @@ function feedEntries(shipment: Shipment): StatusFeedEntry[] {
* «Ciclo de vida del envío» de S08: las cuatro etapas con su avance, y debajo
* la bitácora completa de `ShipmentEvents` con los dos estados de cada evento.
*/
-export function ShipmentLifecycleCard({ shipment, action }: ShipmentLifecycleCardProps) {
+export function ShipmentLifecycleCard({
+ shipment,
+ action,
+ pickup = false,
+}: ShipmentLifecycleCardProps) {
return (
@@ -111,7 +115,7 @@ export function ShipmentLifecycleCard({ shipment, action }: ShipmentLifecycleCar
/>
>
) : (
-
+
)}
)
diff --git a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.types.ts b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.types.ts
index 2b72922..7fb2d03 100644
--- a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.types.ts
+++ b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.types.ts
@@ -9,4 +9,6 @@ export interface ShipmentLifecycleCardProps {
* pendiente (TESIS-134). La decide la página; la tarjeta sólo la ubica.
*/
action?: ReactNode
+ /** La orden la retira el cliente: no lleva envío (TESIS-162). */
+ pickup?: boolean
}
diff --git a/src/features/orders/components/ShipmentStateMessage.tsx b/src/features/orders/components/ShipmentStateMessage.tsx
index 7dbf897..45f1acc 100644
--- a/src/features/orders/components/ShipmentStateMessage.tsx
+++ b/src/features/orders/components/ShipmentStateMessage.tsx
@@ -9,6 +9,11 @@ const messages = ordersCopy.detail.shipmentState
interface ShipmentStateMessageProps {
/** Cualquier estado del envío salvo el resuelto: ése lo dibuja cada panel. */
view: Exclude
+ /**
+ * La orden la retira el cliente (TESIS-162). Cambia lo que significa «no hay
+ * envío»: no es que falte, es que no lleva.
+ */
+ pickup?: boolean
}
/**
@@ -16,14 +21,14 @@ interface ShipmentStateMessageProps {
* comparten el ciclo de vida y los datos del envío, así los dos dicen lo mismo
* con las mismas palabras.
*/
-export function ShipmentStateMessage({ view }: ShipmentStateMessageProps) {
+export function ShipmentStateMessage({ view, pickup = false }: ShipmentStateMessageProps) {
switch (view.kind) {
case 'loading':
return
case 'none':
return (
- {messages.none}
+ {pickup ? messages.pickup : messages.none}
)
case 'duplicated':
diff --git a/src/features/orders/content.ts b/src/features/orders/content.ts
index 76c21fa..5fe6da3 100644
--- a/src/features/orders/content.ts
+++ b/src/features/orders/content.ts
@@ -2,6 +2,8 @@
// criterio que `features/inventory/content.ts`: si más adelante entra i18n,
// este módulo es el único punto a migrar a claves de traducción.
+import { formatInteger } from 'shared/utils'
+
export const ordersCopy = {
page: {
title: 'Órdenes',
@@ -73,6 +75,9 @@ export const ordersCopy = {
},
shipmentState: {
none: 'La orden todavía no tiene un envío creado.',
+ // Distinto de `none`: a esta orden no le falta el envío, es que no lleva
+ // (TESIS-162). Hasta ahora las dos se veían igual.
+ pickup: 'El cliente retira esta orden en el local. No lleva envío.',
duplicated: (count: number) =>
`La orden tiene ${count} envíos registrados y debería tener uno solo. No se muestra ninguno hasta que se corrija.`,
error: 'No pudimos cargar el envío de la orden.',
@@ -130,7 +135,7 @@ export const ordersCopy = {
/** La orden no registra cómo se pagó. */
paymentMethodUnknown: 'Medio de pago sin registrar',
units: 'Unidades',
- lines: (count: number) => `${formatCount(count)} ${count === 1 ? 'línea' : 'líneas'}`,
+ lines: (count: number) => `${formatInteger(count)} ${count === 1 ? 'línea' : 'líneas'}`,
carrier: 'Operador logístico',
/** El courier se asigna al confirmar el despacho. */
noCarrier: 'Sin asignar',
@@ -185,6 +190,8 @@ export const ordersCopy = {
shipping: 'Envío',
/** El envío todavía no tiene costo: no es gratis, falta cotizarlo. */
shippingPending: 'Sin cotizar',
+ /** Un retiro no se va a cotizar nunca: no lleva envío (TESIS-162). */
+ shippingPickup: 'Retiro en el local',
total: 'Total',
invoice: 'Descargar factura',
invoicePending: 'La factura todavía no se puede generar desde el sistema.',
@@ -201,7 +208,7 @@ export const ordersCopy = {
empty: 'La orden no tiene líneas.',
/** "3 líneas · 67 unidades". */
footer: (lines: number, units: number) =>
- `${formatCount(lines)} ${lines === 1 ? 'línea' : 'líneas'} · ${formatCount(units)} ${
+ `${formatInteger(lines)} ${lines === 1 ? 'línea' : 'líneas'} · ${formatInteger(units)} ${
units === 1 ? 'unidad' : 'unidades'
}`,
},
@@ -275,7 +282,7 @@ export const ordersCopy = {
empty: 'Buscá un producto y agregalo para armar la orden.',
/** "3 SKU seleccionados · las cantidades ajustan el peso estimado". */
footer: (count: number) =>
- `${formatCount(count)} SKU ${count === 1 ? 'seleccionado' : 'seleccionados'} · las cantidades ajustan el peso estimado`,
+ `${formatInteger(count)} SKU ${count === 1 ? 'seleccionado' : 'seleccionados'} · las cantidades ajustan el peso estimado`,
},
summary: {
subtotal: 'Subtotal de productos',
@@ -300,7 +307,7 @@ export const ordersCopy = {
loading: 'Calculando stock…',
full: 'Stock suficiente',
/** "Falta stock de 1 SKU". */
- partial: (count: number) => `Falta stock de ${formatCount(count)} SKU`,
+ partial: (count: number) => `Falta stock de ${formatInteger(count)} SKU`,
none: 'Sin stock para la orden',
},
/** Por qué un depósito está deshabilitado, para el lector de pantalla. */
@@ -308,8 +315,19 @@ export const ordersCopy = {
noneCovers:
'Ningún depósito tiene stock para toda la orden. Volvé al paso anterior y ajustá las cantidades.',
},
+ // Cómo llega la orden al cliente (TESIS-162). Va antes del domicilio
+ // porque decide si hace falta cargarlo.
+ fulfillment: {
+ title: 'Cómo la recibe el cliente',
+ shipped: 'Envío a domicilio',
+ shippedHint: 'Se cotiza con los operadores conectados y se despacha.',
+ pickup: 'Retiro en el local',
+ pickupHint: 'El cliente la busca por el depósito. No se cotiza ni se despacha.',
+ },
destination: {
title: 'Domicilio de entrega',
+ /** Con retiro en el local el domicilio deja de exigirse. */
+ optionalTitle: 'Domicilio del cliente (opcional)',
fields: {
address: 'Calle y número',
city: 'Ciudad',
@@ -343,7 +361,7 @@ export const ordersCopy = {
cheapest: 'Más económico',
priceCaption: 'Tarifa',
/** "Entrega en 3 días" · "Entrega en 1 día". */
- eta: (days: number) => `Entrega en ${formatCount(days)} ${days === 1 ? 'día' : 'días'}`,
+ eta: (days: number) => `Entrega en ${formatInteger(days)} ${days === 1 ? 'día' : 'días'}`,
noEta: 'Plazo no informado',
error: 'No pudimos cotizar el envío.',
empty:
@@ -372,6 +390,16 @@ export const ordersCopy = {
/** "Orden #8829 creada y despachada con Andreani." */
confirmed: (orderLabel: string, carrier: string) =>
`Orden ${orderLabel} creada y despachada con ${carrier}.`,
+ // Lo que ocupa el lugar de las cotizaciones cuando la venta se retira en
+ // el local: no hay operador que elegir (TESIS-162).
+ pickup: {
+ title: 'Retiro en el local',
+ body: (warehouse: string) =>
+ `El cliente retira la orden en ${warehouse}. No se cotiza ni se despacha ningún envío.`,
+ },
+ /** Con retiro en el local no hay despacho que anunciar (TESIS-162). */
+ confirmedPickup: (orderLabel: string) =>
+ `Orden ${orderLabel} creada. El cliente la retira en el local.`,
errors: {
order: 'No pudimos crear la orden.',
/** La orden ya existe: lo que falló es el envío o el despacho. */
@@ -469,16 +497,16 @@ export const ordersCopy = {
* "Las 2 líneas de PRO-2294-K piden 3 unidades más de las que hay en CD Ezeiza."
*/
overStock: (sku: string, warehouse: string, missing: number, lines: number) =>
- `${lines === 1 ? sku : `Las ${formatCount(lines)} líneas de ${sku}`} ${
+ `${lines === 1 ? sku : `Las ${formatInteger(lines)} líneas de ${sku}`} ${
lines === 1 ? 'pide' : 'piden'
- } ${formatCount(missing)} ${missing === 1 ? 'unidad más' : 'unidades más'} de las que hay en ${warehouse}.`,
+ } ${formatInteger(missing)} ${missing === 1 ? 'unidad más' : 'unidades más'} de las que hay en ${warehouse}.`,
stockError:
'No pudimos cargar el stock de los productos: la validación queda para el guardado.',
add: {
warehouse: 'Depósito de la línea nueva',
warehousePlaceholder: 'Elegí un depósito',
},
- footer: (count: number) => `${formatCount(count)} ${count === 1 ? 'línea' : 'líneas'}`,
+ footer: (count: number) => `${formatInteger(count)} ${count === 1 ? 'línea' : 'líneas'}`,
},
recalc: {
title: 'Recálculo',
@@ -499,6 +527,8 @@ export const ordersCopy = {
menuFor: (orderId: string) => `Acciones de la orden ${orderId}`,
},
cells: {
+ /** La orden no espera courier: la retira el cliente (TESIS-162). */
+ pickup: 'Retiro en el local',
/** El courier se asigna al confirmar el despacho: antes de eso no hay. */
noCarrier: 'Sin asignar',
/** Una venta cargada a mano puede no tener dirección. */
@@ -511,11 +541,6 @@ export const ordersCopy = {
page: (page: number) => `Ir a la página ${page}`,
/** "Mostrando 1 a 20 de 4.829 órdenes". */
summary: (from: number, to: number, total: number) =>
- `Mostrando ${from} a ${to} de ${formatCount(total)} ${total === 1 ? 'orden' : 'órdenes'}`,
+ `Mostrando ${from} a ${to} de ${formatInteger(total)} ${total === 1 ? 'orden' : 'órdenes'}`,
},
}
-
-/** Miles con punto, como el resto de los números de la pantalla. */
-export function formatCount(value: number): string {
- return new Intl.NumberFormat('es-AR').format(value)
-}
diff --git a/src/features/orders/hooks/useConfirmDraftOrder.test.tsx b/src/features/orders/hooks/useConfirmDraftOrder.test.tsx
index 547bf9b..353dc69 100644
--- a/src/features/orders/hooks/useConfirmDraftOrder.test.tsx
+++ b/src/features/orders/hooks/useConfirmDraftOrder.test.tsx
@@ -19,6 +19,7 @@ const INPUT: ConfirmDraftOrderInput = {
customer_city: 'CABA',
customer_province: 'Ciudad Autónoma de Buenos Aires',
customer_zip_code: '1193',
+ requires_shipping: true,
items: [{ product_id: 12, warehouse_id: 3, quantity: 4, unit_price: 120000 }],
},
},
diff --git a/src/features/orders/hooks/useConfirmDraftOrder.ts b/src/features/orders/hooks/useConfirmDraftOrder.ts
index 3a55161..472c64a 100644
--- a/src/features/orders/hooks/useConfirmDraftOrder.ts
+++ b/src/features/orders/hooks/useConfirmDraftOrder.ts
@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useRef, useState } from 'react'
+import { activityKeys } from 'shared/api/activity'
import type { ApiRequestError } from 'shared/api/types'
import { createOrder, createOrderShipment, dispatchShipment } from '../api'
@@ -8,7 +9,11 @@ import type { CreateOrderPayload, DispatchPayload } from '../utils/shipping'
export interface ConfirmDraftOrderInput {
order: CreateOrderPayload
- dispatch: DispatchPayload
+ /**
+ * `null` cuando la venta se retira en el local (TESIS-162): no hay envío que
+ * abrir ni courier que despachar, así que la confirmación termina en el alta.
+ */
+ dispatch: DispatchPayload | null
}
interface Progress {
@@ -51,6 +56,9 @@ export function useConfirmDraftOrder({ onOrderCreated }: Options = {}) {
}
const orderId = progress.current.orderId
+ // Retiro en el local: la venta queda registrada y ahí termina.
+ if (dispatch === null) return orderId
+
if (progress.current.shipmentId === null) {
progress.current.shipmentId = (await createOrderShipment(orderId)).id
}
@@ -66,8 +74,13 @@ export function useConfirmDraftOrder({ onOrderCreated }: Options = {}) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: orderKeys.all }),
- // Literal y no la factory de inventario: una feature no importa otra.
+ // El alta abre un envío y deja su rastro en la campanita, así que el
+ // listado de envíos, los contadores de sus pestañas y el feed quedan
+ // viejos lo mismo que el listado de órdenes. `['shipments']` va literal
+ // porque es de otra feature; la actividad vive en `shared`.
queryClient.invalidateQueries({ queryKey: ['inventory'] }),
+ queryClient.invalidateQueries({ queryKey: ['shipments'] }),
+ queryClient.invalidateQueries({ queryKey: activityKeys.all }),
])
},
})
diff --git a/src/features/orders/hooks/useDispatchShipment.test.tsx b/src/features/orders/hooks/useDispatchShipment.test.tsx
index 3e9d966..e0c06ac 100644
--- a/src/features/orders/hooks/useDispatchShipment.test.tsx
+++ b/src/features/orders/hooks/useDispatchShipment.test.tsx
@@ -25,6 +25,11 @@ function seededClient() {
queryClient.setQueryData(orderKeys.detail(8829), {})
queryClient.setQueryData(orderKeys.shipment(8829), { kind: 'none' })
queryClient.setQueryData(quoteKeys.order(8829, 1), [])
+ // El listado de envíos, el contador de una pestaña y la campanita: tres
+ // consumidores de fuera de esta feature que el despacho deja viejos.
+ queryClient.setQueryData(['shipments', 'list', {}], [])
+ queryClient.setQueryData(['shipments', 'count', 'in_transit', ''], 0)
+ queryClient.setQueryData(['activity', 'list'], [])
return queryClient
}
@@ -63,6 +68,24 @@ describe('useDispatchShipment', () => {
expect(queryClient.getQueryState(quoteKeys.order(8829, 1))?.isInvalidated).toBe(false)
})
+ // El envío que se acaba de despachar cambia de estado y de courier. Quien lo
+ // muestra no es sólo el detalle de la orden: están el listado de /shipments,
+ // los contadores de sus pestañas y el historial de la campanita, y ninguno se
+ // entera por el dominio de órdenes.
+ it('refreshes the shipments listing and the activity feed', async () => {
+ vi.spyOn(api, 'dispatchShipment').mockResolvedValue(SHIPMENT)
+ const queryClient = seededClient()
+ const { result } = renderFor(queryClient)
+
+ await act(() => result.current.mutateAsync(INPUT))
+
+ expect(queryClient.getQueryState(['shipments', 'list', {}])?.isInvalidated).toBe(true)
+ expect(queryClient.getQueryState(['shipments', 'count', 'in_transit', ''])?.isInvalidated).toBe(
+ true,
+ )
+ expect(queryClient.getQueryState(['activity', 'list'])?.isInvalidated).toBe(true)
+ })
+
// Un 409 es que otro lo despachó mientras tanto: el detalle tiene que dejar de
// ofrecerlo.
it('refreshes the shipment also when the dispatch fails', async () => {
diff --git a/src/features/orders/hooks/useDispatchShipment.ts b/src/features/orders/hooks/useDispatchShipment.ts
index 054fa96..3325b8b 100644
--- a/src/features/orders/hooks/useDispatchShipment.ts
+++ b/src/features/orders/hooks/useDispatchShipment.ts
@@ -1,4 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { activityKeys } from 'shared/api/activity'
import type { ApiRequestError } from 'shared/api/types'
import { dispatchShipment } from '../api'
@@ -26,6 +27,15 @@ export function useDispatchShipment() {
// También si falló: un 409 quiere decir que otro lo despachó mientras tanto,
// y el detalle tiene que dejar de ofrecerlo. Invalidar el dominio entero
// refresca además el courier que muestra el listado.
- onSettled: () => queryClient.invalidateQueries({ queryKey: orderKeys.all }),
+ onSettled: () =>
+ Promise.all([
+ queryClient.invalidateQueries({ queryKey: orderKeys.all }),
+ // Despachar cambia el estado del envío: el listado de /shipments, los
+ // contadores de sus pestañas y la campanita lo muestran y no se enteran
+ // por el dominio de órdenes. Literales porque una feature no importa
+ // las claves de otra.
+ queryClient.invalidateQueries({ queryKey: ['shipments'] }),
+ queryClient.invalidateQueries({ queryKey: activityKeys.all }),
+ ]),
})
}
diff --git a/src/features/orders/hooks/useUpdateOrder.ts b/src/features/orders/hooks/useUpdateOrder.ts
index e07b5c4..3859bf5 100644
--- a/src/features/orders/hooks/useUpdateOrder.ts
+++ b/src/features/orders/hooks/useUpdateOrder.ts
@@ -1,4 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { activityKeys } from 'shared/api/activity'
import type { ApiRequestError } from 'shared/api/types'
import { updateOrder } from '../api'
@@ -28,6 +29,9 @@ export function useUpdateOrder(id: number, version: string | null) {
queryClient.invalidateQueries({ queryKey: orderKeys.all }),
// Literal y no la factory de inventario: una feature no importa otra.
queryClient.invalidateQueries({ queryKey: ['inventory'] }),
+ // Modificar una orden puede dejarla sin envío o cambiarle el destino.
+ queryClient.invalidateQueries({ queryKey: ['shipments'] }),
+ queryClient.invalidateQueries({ queryKey: activityKeys.all }),
]),
})
}
diff --git a/src/features/orders/pages/CarrierStepPage.test.tsx b/src/features/orders/pages/CarrierStepPage.test.tsx
index 91365f1..f7e93e4 100644
--- a/src/features/orders/pages/CarrierStepPage.test.tsx
+++ b/src/features/orders/pages/CarrierStepPage.test.tsx
@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-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 { notify, useOrderDraftStore } from 'shared/store'
import type * as sharedStore from 'shared/store'
@@ -117,6 +117,88 @@ beforeEach(() => {
mockQuotes()
})
+// TESIS-162: con retiro en el local la venta no entra al circuito logístico,
+// así que el paso 3 no cotiza ni despacha.
+describe('CarrierStepPage · pickup at the store', () => {
+ beforeEach(() => {
+ useOrderDraftStore.getState().setRequiresShipping(false)
+ })
+
+ it('does not ask the carriers for a quote', () => {
+ renderPage()
+
+ expect(screen.getByText('Retiro en el local')).toBeInTheDocument()
+ expect(screen.queryByRole('radio')).not.toBeInTheDocument()
+ })
+
+ it('can be confirmed without choosing a carrier', () => {
+ renderPage()
+
+ expect(confirmButton()).toBeEnabled()
+ })
+
+ // El alta y nada más: no hay envío que abrir ni etiqueta que pedir.
+ it('creates the order and stops there', async () => {
+ const calls = stubConfirmation()
+ renderPage()
+
+ fireEvent.click(confirmButton())
+
+ await waitFor(() => expect(calls.createOrder).toHaveBeenCalled())
+ expect(calls.createShipment).not.toHaveBeenCalled()
+ expect(calls.dispatch).not.toHaveBeenCalled()
+ })
+
+ it('tells the API that the order is picked up', async () => {
+ const calls = stubConfirmation()
+ renderPage()
+
+ fireEvent.click(confirmButton())
+
+ await waitFor(() => expect(calls.createOrder).toHaveBeenCalled())
+ expect(calls.createOrder.mock.calls[0][0].order.requires_shipping).toBe(false)
+ })
+
+ // Apenas la orden existe, el borrador se vacía, y `requiresShipping` vuelve a
+ // su default `true`. Si la pantalla lo leyera del store en vez de la copia
+ // congelada, una venta de retiro saldría a pedirles precio a los couriers en
+ // mitad de la confirmación.
+ // Apenas la orden existe, `onOrderCreated` vacía el borrador y
+ // `requiresShipping` vuelve a su default `true`. Lo que se muestra desde ese
+ // momento sale de la copia congelada al apretar el botón: si saliera del
+ // store, una venta de retiro saldría a pedirles precio a los couriers en
+ // mitad de la confirmación, y el confirmar quedaría deshabilitado por no
+ // tener ninguna opción elegida.
+ it('does not quote once the draft was cleared mid-confirmation', async () => {
+ stubConfirmation()
+ renderPage()
+
+ fireEvent.click(confirmButton())
+ await act(async () => {
+ useOrderDraftStore.getState().clearDraft()
+ })
+
+ expect(useOrderDraftStore.getState().requiresShipping).toBe(true)
+ // `null` es «no cotices», y el hook lo recibe en cada render.
+ const calls = vi.mocked(useDraftQuotes).mock.calls
+ expect(calls.every((call) => call[0] === null)).toBe(true)
+ })
+
+ it('announces the pickup instead of a dispatch', async () => {
+ stubConfirmation()
+ renderPage()
+
+ fireEvent.click(confirmButton())
+
+ await waitFor(() =>
+ expect(notify).toHaveBeenCalledWith(
+ expect.stringContaining('El cliente la retira en el local'),
+ 'success',
+ ),
+ )
+ })
+})
+
describe('CarrierStepPage', () => {
it('marks step 3 as the active one', () => {
renderPage()
diff --git a/src/features/orders/pages/CarrierStepPage.tsx b/src/features/orders/pages/CarrierStepPage.tsx
index f6acb7d..ed64b9d 100644
--- a/src/features/orders/pages/CarrierStepPage.tsx
+++ b/src/features/orders/pages/CarrierStepPage.tsx
@@ -1,5 +1,6 @@
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
-import { Alert, Box, Button, Stack } from '@mui/material'
+import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
+import { Alert, Box, Button, Stack, Typography } from '@mui/material'
import { useMemo, useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { PageWrapper } from 'shared/components'
@@ -40,6 +41,13 @@ interface ConfirmedDraft {
items: OrderDraftItem[]
origin: OrderDraftOrigin
destination: OrderDraftDestination
+ /**
+ * Cómo lo recibe el cliente, congelado junto con el resto. Se lee de la
+ * copia y no del store: `clearDraft` lo devuelve a su default `true` apenas
+ * la orden existe, y leerlo en vivo hacía que una venta de retiro saliera a
+ * pedirles precio a los couriers en mitad de la confirmación.
+ */
+ requiresShipping: boolean
}
/**
@@ -68,6 +76,7 @@ export function CarrierStepPage() {
const items = useOrderDraftStore((state) => state.items)
const origin = useOrderDraftStore((state) => state.origin)
const destination = useOrderDraftStore((state) => state.destination)
+ const requiresShipping = useOrderDraftStore((state) => state.requiresShipping)
const clearDraft = useOrderDraftStore((state) => state.clearDraft)
const [confirmed, setConfirmed] = useState(null)
@@ -76,15 +85,19 @@ export function CarrierStepPage() {
const live = useMemo(
() =>
customer !== null && items.length > 0 && origin !== null && destination !== null
- ? { customer, items, origin, destination }
+ ? { customer, items, origin, destination, requiresShipping }
: null,
- [customer, items, origin, destination],
+ [customer, items, origin, destination, requiresShipping],
)
const draft = confirmed ?? live
+ // Con retiro en el local no se cotiza: no hay envío que despachar, así que
+ // tampoco hay a quién preguntarle el precio (TESIS-162).
const payload = useMemo(
() =>
- draft === null ? null : toDraftQuotePayload(draft.items, draft.origin, draft.destination),
+ draft?.requiresShipping
+ ? toDraftQuotePayload(draft.items, draft.origin, draft.destination)
+ : null,
[draft],
)
const quotes = useDraftQuotes(payload)
@@ -105,17 +118,30 @@ export function CarrierStepPage() {
const orderLabel = createdOrderId === null ? '' : formatOrderId(null, createdOrderId)
function confirmOrder() {
- if (draft === null || chosen === null) return
+ // Con envío hace falta la opción elegida; con retiro, no hay ninguna.
+ if (draft === null || (draft.requiresShipping && chosen === null)) return
setConfirmed(draft)
confirm.mutate(
{
- order: toCreateOrderPayload(draft.customer, draft.items, draft.origin, draft.destination),
- dispatch: toDispatchPayload(chosen, draft.origin.warehouseId),
+ order: toCreateOrderPayload(
+ draft.customer,
+ draft.items,
+ draft.origin,
+ draft.destination,
+ draft.requiresShipping,
+ ),
+ dispatch: chosen === null ? null : toDispatchPayload(chosen, draft.origin.warehouseId),
},
{
onSuccess: (orderId) => {
- notify(carrier.confirmed(formatOrderId(null, orderId), chosen.providerName), 'success')
+ const label = formatOrderId(null, orderId)
+ notify(
+ chosen === null
+ ? carrier.confirmedPickup(label)
+ : carrier.confirmed(label, chosen.providerName),
+ 'success',
+ )
void navigate(ORDERS_PATH)
},
},
@@ -162,16 +188,30 @@ export function CarrierStepPage() {
}}
>
}
- title={carrier.options.groupLabel}
+ icon={
+ draft.requiresShipping ? (
+
+ ) : (
+
+ )
+ }
+ title={draft.requiresShipping ? carrier.options.groupLabel : carrier.pickup.title}
>
- setSelectedId(quote.dispatchIntegrationId)}
- onReview={() => void navigate(SHIPPING_STEP_PATH)}
- />
+ {/* Con retiro en el local no hay nada que cotizar: la venta se
+ registra y el cliente la busca (TESIS-162). */}
+ {draft.requiresShipping ? (
+ setSelectedId(quote.dispatchIntegrationId)}
+ onReview={() => void navigate(SHIPPING_STEP_PATH)}
+ />
+ ) : (
+
+ {carrier.pickup.body(draft.origin.name)}
+
+ )}
{
expect(screen.getByText('Pendiente de despacho')).toBeInTheDocument()
})
+ // TESIS-162: no es lo mismo que le falte el envío que que no lleve.
+ it('says the customer picks the order up instead of saying the shipment is missing', () => {
+ mockQueries({ data: { ...ORDER, requiresShipping: false } }, { data: { kind: 'none' } })
+
+ renderAt('/orders/8829')
+
+ expect(
+ screen.getByText('El cliente retira esta orden en el local. No lleva envío.'),
+ ).toBeInTheDocument()
+ })
+
+ // El criterio de la card: «una orden con retiro en local no ofrece crear
+ // envío». El botón lo trajo TESIS-141, que mira el estado y si el envío
+ // existe, y git no marcó conflicto porque cada rama tocó líneas distintas.
+ // Crearlo responde 422 (PickupOrderError), así que era un botón que sólo
+ // sabía fallar.
+ it('does not offer to create a shipment for an order picked up at the store', () => {
+ mockQueries({ data: { ...ORDER, requiresShipping: false } }, { data: { kind: 'none' } })
+
+ renderAt('/orders/8829')
+
+ expect(screen.queryByRole('button', { name: 'Crear envío' })).not.toBeInTheDocument()
+ })
+
+ it('still offers it for an order that ships', () => {
+ mockQueries({ data: ORDER }, { data: { kind: 'none' } })
+
+ renderAt('/orders/8829')
+
+ expect(screen.getByRole('button', { name: 'Crear envío' })).toBeInTheDocument()
+ })
+
+ // El panel entero hablaba de un envío que la orden no lleva: «Pendiente de
+ // despacho» y «Se emite al despachar» de algo que no va a existir.
+ it('hides the shipping panel of an order picked up at the store', () => {
+ mockQueries({ data: { ...ORDER, requiresShipping: false } }, { data: { kind: 'none' } })
+
+ renderAt('/orders/8829')
+
+ expect(screen.queryByText('Datos del envío')).not.toBeInTheDocument()
+ expect(screen.queryByText('Pendiente de despacho')).not.toBeInTheDocument()
+ })
+
+ it('keeps the shipping panel for an order that ships', () => {
+ mockQueries({ data: ORDER }, { data: { kind: 'none' } })
+
+ renderAt('/orders/8829')
+
+ expect(screen.getByText('Datos del envío')).toBeInTheDocument()
+ })
+
+ // Un retiro no está «sin cotizar»: no se va a cotizar nunca.
+ it('says the pickup has no shipping cost instead of leaving it unquoted', () => {
+ mockQueries({ data: { ...ORDER, requiresShipping: false } }, { data: { kind: 'none' } })
+
+ renderAt('/orders/8829')
+
+ expect(screen.getByText('Retiro en el local')).toBeInTheDocument()
+ expect(screen.queryByText('Sin cotizar')).not.toBeInTheDocument()
+ })
+
+ it('still says the shipment is missing for an order that is shipped', () => {
+ mockQueries({ data: ORDER }, { data: { kind: 'none' } })
+
+ renderAt('/orders/8829')
+
+ expect(screen.getByText('La orden todavía no tiene un envío creado.')).toBeInTheDocument()
+ })
+
it('leaves the shipping unquoted and the courier unassigned without a shipment', () => {
mockQueries({ data: ORDER }, { data: { kind: 'none' } })
diff --git a/src/features/orders/pages/OrderDetailPage.tsx b/src/features/orders/pages/OrderDetailPage.tsx
index 11a6967..34b66e4 100644
--- a/src/features/orders/pages/OrderDetailPage.tsx
+++ b/src/features/orders/pages/OrderDetailPage.tsx
@@ -9,6 +9,7 @@ import type { ReactNode } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components'
import { notify } from 'shared/store'
+import { formatInteger, formatMoney } from 'shared/utils'
import { DispatchShipmentDialog } from '../components/DispatchShipmentDialog'
import { InfoPanel } from '../components/InfoPanel'
@@ -21,12 +22,12 @@ import { PaymentSummaryCard } from '../components/PaymentSummaryCard'
import { ShipmentLifecycleCard } from '../components/ShipmentLifecycleCard'
import { ShippingLabelField } from '../components/ShippingLabelField'
import { TrackingNumberField } from '../components/TrackingNumberField'
-import { formatCount, ordersCopy } from '../content'
+import { ordersCopy } from '../content'
import { useCreateOrderShipment } from '../hooks/useCreateOrderShipment'
import { useOrder, useOrderShipment } from '../hooks/useOrderDetail'
import type { OrderDetail, Shipment, ShipmentView } from '../types'
import { canOpenShipment, dispatchableShipment } from '../utils/dispatch'
-import { formatMoney, formatOrderId, formatShortDate } from '../utils/format'
+import { formatOrderId, formatShortDate } from '../utils/format'
import { paymentSummary, totalUnits } from '../utils/payment'
import { deliveredAt, headerStatus } from '../utils/shipment'
@@ -76,7 +77,7 @@ function buildMetrics(order: OrderDetail, shipment: Shipment | null, total: numb
{
id: 'units',
label: metrics.units,
- value: formatCount(totalUnits(order.lines)),
+ value: formatInteger(totalUnits(order.lines)),
note: metrics.lines(order.lines.length),
icon: ,
},
@@ -233,7 +234,7 @@ export function OrderDetailPage() {
const payment = paymentSummary(order.data, resolved?.shippingCost ?? null)
const status = headerStatus(order.data.status, shipment.data)
const dispatchable = dispatchableShipment(order.data.status, shipmentView)
- const canOpen = canOpenShipment(order.data.status, shipmentView)
+ const canOpen = canOpenShipment(order.data.status, shipmentView, order.data.requiresShipping)
const orderLabel = formatOrderId(order.data.externalOrderId, order.data.id)
// El id suelto y no `order.data.id` dentro del callback: el angostado de los
// returns de arriba no alcanza adentro de una función, que TypeScript no sabe
@@ -270,6 +271,7 @@ export function OrderDetailPage() {
- }
- fields={SHIPPING_FIELDS}
- footnote={detail.shipping.footnote}
- >
- {/* Sin envío resuelto el tracking tampoco existe: el panel dice
- «Pendiente de despacho», que es lo mismo que ve el operador
- mientras el courier no confirma. La etiqueta sigue el mismo
- criterio: la emite el courier al despachar (RF-23). */}
-
-
-
+ {/* En un retiro el panel entero habla de algo que no va a existir:
+ decía «Pendiente de despacho» y «Se emite al despachar» de un
+ envío que la orden no lleva. No se muestra, y la tarjeta del
+ ciclo de vida es la que explica por qué (TESIS-162). */}
+ {order.data.requiresShipping ? (
+ }
+ fields={SHIPPING_FIELDS}
+ footnote={detail.shipping.footnote}
+ >
+ {/* Sin envío resuelto el tracking tampoco existe: el panel dice
+ «Pendiente de despacho», que es lo mismo que ve el operador
+ mientras el courier no confirma. La etiqueta sigue el mismo
+ criterio: la emite el courier al despachar (RF-23). */}
+
+
+
+ ) : null}
diff --git a/src/features/orders/pages/OrderEditPage.test.tsx b/src/features/orders/pages/OrderEditPage.test.tsx
index 4cd0101..f2e5ffe 100644
--- a/src/features/orders/pages/OrderEditPage.test.tsx
+++ b/src/features/orders/pages/OrderEditPage.test.tsx
@@ -34,6 +34,7 @@ const ORDER: OrderDetail = {
customerCity: 'CABA',
customerProvince: 'Ciudad Autónoma de Buenos Aires',
status: 'pending',
+ requiresShipping: true,
version: '"v1"',
totalAmount: 1260000,
lines: [
diff --git a/src/features/orders/pages/OrdersPage.tsx b/src/features/orders/pages/OrdersPage.tsx
index 750f72e..48ba632 100644
--- a/src/features/orders/pages/OrdersPage.tsx
+++ b/src/features/orders/pages/OrdersPage.tsx
@@ -6,9 +6,10 @@ 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 { formatInteger } from 'shared/utils'
import { OrdersTable } from '../components/OrdersTable'
-import { formatCount, ordersCopy } from '../content'
+import { ordersCopy } from '../content'
import { ORDER_TABS, useOrderCounts, useOrderPage } from '../hooks/useOrders'
import type { OrderTabId } from '../hooks/useOrders'
import type { OrderSummary } from '../types'
@@ -65,7 +66,7 @@ export function OrdersPage() {
label: TAB_LABELS[id],
// Un contador que todavía no resolvió no muestra cero: mostraría un número
// falso durante el primer render y luego saltaría al real.
- count: counts[index] === undefined ? undefined : formatCount(counts[index]),
+ count: counts[index] === undefined ? undefined : formatInteger(counts[index]),
}))
if (orders.isPending) return
diff --git a/src/features/orders/pages/ShippingStepPage.test.tsx b/src/features/orders/pages/ShippingStepPage.test.tsx
index 09a6ab7..41955ea 100644
--- a/src/features/orders/pages/ShippingStepPage.test.tsx
+++ b/src/features/orders/pages/ShippingStepPage.test.tsx
@@ -113,7 +113,10 @@ describe('ShippingStepPage', () => {
it('offers exactly the warehouses the company has', () => {
renderPage()
- expect(screen.getAllByRole('radio')).toHaveLength(2)
+ // Acotado al grupo de depósitos: desde TESIS-162 la pantalla tiene otro
+ // grupo de radios, el de envío contra retiro en el local.
+ const origins = screen.getByRole('radiogroup', { name: 'Depósito de origen de la orden' })
+ expect(within(origins).getAllByRole('radio')).toHaveLength(2)
expect(option(/CD Ezeiza/)).toBeInTheDocument()
expect(option(/CD Córdoba/)).toBeInTheDocument()
})
@@ -251,3 +254,49 @@ describe('ShippingStepPage', () => {
expect(await screen.findByRole('heading', { name: 'Paso 1' })).toBeInTheDocument()
})
})
+
+// TESIS-162: con retiro en el local el destino deja de pedirse. El comentario
+// del código lo decía y el formulario lo seguía exigiendo igual.
+describe('ShippingStepPage · pickup at the store', () => {
+ function choosePickup() {
+ fireEvent.click(screen.getByRole('radio', { name: /Retiro en el local/ }))
+ }
+
+ it('lets the step advance without a delivery address', async () => {
+ renderPage()
+ fireEvent.click(option(/CD Ezeiza/))
+ choosePickup()
+
+ await waitFor(() => expect(nextButton()).toBeEnabled())
+ })
+
+ it('says the address is optional instead of demanding it', () => {
+ renderPage()
+ choosePickup()
+
+ expect(screen.getByText(/Domicilio del cliente \(opcional\)/)).toBeInTheDocument()
+ })
+
+ // Se guarda lo que haya: el paso 3 pide un destino para no mandar de vuelta
+ // al 2, y el domicilio del cliente puede hacer falta para la factura.
+ it('still carries whatever address was typed into the draft', async () => {
+ renderPage()
+ fireEvent.click(option(/CD Ezeiza/))
+ await fillDestination()
+ choosePickup()
+
+ fireEvent.click(nextButton())
+
+ await waitFor(() =>
+ expect(useOrderDraftStore.getState().destination).toMatchObject({ city: 'CABA' }),
+ )
+ })
+
+ // Control negativo: con envío a domicilio el destino se sigue exigiendo.
+ it('keeps demanding it when the order ships', () => {
+ renderPage()
+ fireEvent.click(option(/CD Ezeiza/))
+
+ expect(nextButton()).toBeDisabled()
+ })
+})
diff --git a/src/features/orders/pages/ShippingStepPage.tsx b/src/features/orders/pages/ShippingStepPage.tsx
index 261bd6d..710b870 100644
--- a/src/features/orders/pages/ShippingStepPage.tsx
+++ b/src/features/orders/pages/ShippingStepPage.tsx
@@ -1,8 +1,17 @@
import { zodResolver } from '@hookform/resolvers/zod'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'
+import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined'
-import { Alert, Button, Stack, Typography } from '@mui/material'
+import {
+ Alert,
+ Button,
+ FormControlLabel,
+ Radio,
+ RadioGroup,
+ Stack,
+ Typography,
+} from '@mui/material'
import { useEffect, useMemo } from 'react'
import { useForm } from 'react-hook-form'
import { Navigate, useNavigate } from 'react-router-dom'
@@ -22,6 +31,7 @@ import { warehouseCoverage } from '../utils/shipping'
import type { WarehouseCoverage } from '../utils/shipping'
const { wizard, shipping } = ordersCopy
+const { fulfillment } = shipping
// Mismo criterio que el paso 1: las rutas se declaran acá porque una feature no
// puede importar el router (architecture.md §3.2). El paso 3 lo construye
@@ -57,6 +67,8 @@ export function ShippingStepPage() {
const destination = useOrderDraftStore((state) => state.destination)
const setOrigin = useOrderDraftStore((state) => state.setOrigin)
const setDestination = useOrderDraftStore((state) => state.setDestination)
+ const requiresShipping = useOrderDraftStore((state) => state.requiresShipping)
+ const setRequiresShipping = useOrderDraftStore((state) => state.setRequiresShipping)
const warehouses = useOriginWarehouses()
const stocks = useProductStocks(items.map((item) => item.productId))
@@ -116,10 +128,19 @@ export function ShippingStepPage() {
return
}
- const next = handleSubmit((data) => {
- setDestination(data)
- void navigate(CARRIER_STEP_PATH)
- })
+ // Con retiro en el local el domicilio deja de ser obligatorio: la orden no va
+ // a ningún lado. Se guarda igual lo que haya escrito —puede hacer falta para
+ // la factura— y por eso el paso 3 sigue encontrando un destino, que es lo que
+ // necesita para no mandar de vuelta al 2.
+ const next = requiresShipping
+ ? handleSubmit((data) => {
+ setDestination(data)
+ void navigate(CARRIER_STEP_PATH)
+ })
+ : () => {
+ setDestination(getValues())
+ void navigate(CARRIER_STEP_PATH)
+ }
function back() {
setDestination(getValues())
@@ -142,6 +163,42 @@ export function ShippingStepPage() {
/>
+ {/* Antes del domicilio: con retiro en el local, el destino deja de
+ pedirse porque la orden no va a ningún lado (TESIS-162). */}
+ } title={fulfillment.title}>
+ setRequiresShipping(event.target.value === 'shipped')}
+ >
+ }
+ label={
+ <>
+ {fulfillment.shipped}
+
+ {fulfillment.shippedHint}
+
+ >
+ }
+ />
+ }
+ label={
+ <>
+ {fulfillment.pickup}
+
+ {fulfillment.pickupHint}
+
+ >
+ }
+ />
+
+
+
+ {/* Se muestra igual con retiro: el domicilio del cliente puede hacer
+ falta para la factura. Lo que cambia es que deja de exigirse. */}
{/* `useFlexGap`: sin él `spacing` separa con `margin-left` y pisa el
@@ -161,7 +219,7 @@ export function ShippingStepPage() {
variant="contained"
size="large"
endIcon={}
- disabled={!isValid || selectedId === null}
+ disabled={(requiresShipping && !isValid) || selectedId === null}
onClick={() => void next()}
sx={{ ml: 'auto' }}
>
diff --git a/src/features/orders/types.ts b/src/features/orders/types.ts
index 360b78f..f1e86bf 100644
--- a/src/features/orders/types.ts
+++ b/src/features/orders/types.ts
@@ -31,6 +31,12 @@ export interface OrderSummary {
customerAddress: string | null
customerZipCode: string | null
status: OrderStatus
+ /**
+ * Si la orden se despacha o la retira el cliente en el local (TESIS-162).
+ * El listado lo marca para que se vea sin entrar: una orden de retiro no
+ * espera envío, y hasta ahora no se distinguía de una a la que le falta.
+ */
+ requiresShipping: boolean
carrier: string | null
totalAmount: number | null
itemCount: number
@@ -77,6 +83,8 @@ export interface OrderDetail {
customerCity: string | null
customerProvince: string | null
status: OrderStatus
+ /** Si se despacha o la retira el cliente en el local (TESIS-162). */
+ requiresShipping: boolean
/**
* La versión de la orden, tal como vino en el `ETag` del detalle (TESIS-126).
* Viaja de vuelta en `If-Match` al guardar la modificación. `null` si el
diff --git a/src/features/orders/utils/dispatch.test.ts b/src/features/orders/utils/dispatch.test.ts
index 666847a..20e0b15 100644
--- a/src/features/orders/utils/dispatch.test.ts
+++ b/src/features/orders/utils/dispatch.test.ts
@@ -71,28 +71,34 @@ describe('canOpenShipment', () => {
// El caso que motiva la card: una orden de webhook nace sin envío y hasta
// TESIS-141 no había forma de abrirlo desde la app.
it('lets an order with no shipment open one', () => {
- expect(canOpenShipment('paid', { kind: 'none' })).toBe(true)
+ expect(canOpenShipment('paid', { kind: 'none' }, true)).toBe(true)
})
it('also lets a pending order open it, like the backend does', () => {
- expect(canOpenShipment('pending', { kind: 'none' })).toBe(true)
+ expect(canOpenShipment('pending', { kind: 'none' }, true)).toBe(true)
})
// Misma exclusión que Shipments::CreateShipment::NON_SHIPPABLE_STATUSES.
it('refuses a cancelled order', () => {
- expect(canOpenShipment('cancelled', { kind: 'none' })).toBe(false)
+ expect(canOpenShipment('cancelled', { kind: 'none' }, true)).toBe(false)
+ })
+
+ // La otra exclusión de CreateShipment: un retiro responde PickupOrderError
+ // (422), así que ofrecerlo era ofrecer un botón que siempre falla.
+ it('refuses an order the customer picks up at the store', () => {
+ expect(canOpenShipment('paid', { kind: 'none' }, false)).toBe(false)
})
it('refuses an order that already has one', () => {
- expect(canOpenShipment('paid', { kind: 'single', shipment: PENDING })).toBe(false)
+ expect(canOpenShipment('paid', { kind: 'single', shipment: PENDING }, true)).toBe(false)
})
// Que el envío no exista tiene que ser un hecho: sobre una consulta que
// todavía no resolvió o que falló, ofrecerlo invitaría a un 409.
it('refuses while the shipment is unknown', () => {
- expect(canOpenShipment('paid', { kind: 'loading' })).toBe(false)
- expect(canOpenShipment('paid', { kind: 'error', onRetry: () => undefined })).toBe(false)
- expect(canOpenShipment('paid', { kind: 'duplicated', count: 2 })).toBe(false)
+ expect(canOpenShipment('paid', { kind: 'loading' }, true)).toBe(false)
+ expect(canOpenShipment('paid', { kind: 'error', onRetry: () => undefined }, true)).toBe(false)
+ expect(canOpenShipment('paid', { kind: 'duplicated', count: 2 }, true)).toBe(false)
})
})
diff --git a/src/features/orders/utils/dispatch.ts b/src/features/orders/utils/dispatch.ts
index 0340a99..761b071 100644
--- a/src/features/orders/utils/dispatch.ts
+++ b/src/features/orders/utils/dispatch.ts
@@ -35,12 +35,22 @@ export function dispatchableShipment(
* ofrecía nada.
*
* Es la misma regla que aplica el backend (`Shipments::CreateShipment`): se
- * excluyen las canceladas y nada más. Que el envío no exista tiene que ser un
- * hecho y no una duda, así que mientras la consulta carga o falla no se ofrece:
- * ofrecerlo sobre un envío que no se pudo leer invitaría a un 409.
+ * excluyen las canceladas y los retiros en local. Que el envío no exista tiene
+ * que ser un hecho y no una duda, así que mientras la consulta carga o falla no
+ * se ofrece: ofrecerlo sobre un envío que no se pudo leer invitaría a un 409.
+ *
+ * El retiro entra acá y no en la pantalla porque las dos exclusiones son la
+ * misma regla del backend: una cancelada responde `CancelledOrderError` y un
+ * retiro `PickupOrderError` (TESIS-162), los dos 422. Sin esta condición el
+ * detalle ofrecía «Crear envío» sobre una orden de retiro y el operador se
+ * comía el error genérico al apretarlo.
*/
-export function canOpenShipment(orderStatus: OrderStatus, view: ShipmentView): boolean {
- return orderStatus !== 'cancelled' && view.kind === 'none'
+export function canOpenShipment(
+ orderStatus: OrderStatus,
+ view: ShipmentView,
+ requiresShipping: boolean,
+): boolean {
+ return requiresShipping && orderStatus !== 'cancelled' && view.kind === 'none'
}
/**
diff --git a/src/features/orders/utils/format.test.ts b/src/features/orders/utils/format.test.ts
index 2dd3144..56c0dd7 100644
--- a/src/features/orders/utils/format.test.ts
+++ b/src/features/orders/utils/format.test.ts
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'
+// Los ejemplos de «formatMoney» viven en `shared/utils/number.test.ts` desde
+// TESIS-166: era el formateador compartido, no una regla de esta feature.
+
import {
formatEventTimestamp,
- formatMoney,
formatOrderDate,
formatOrderId,
formatOrderTime,
@@ -10,29 +12,6 @@ import {
formatWeight,
} from './format'
-describe('formatMoney', () => {
- it('groups thousands with a dot', () => {
- expect(formatMoney(1478300)).toContain('1.478.300')
- })
-
- /**
- * El diseño muestra los importes sin decimales, pero esta columna es plata
- * facturada: redondear en pantalla muestra un número que no es el de la
- * orden. Este ejemplo fija la decisión de apartarse del mock.
- */
- it('keeps the cents instead of rounding them away', () => {
- expect(formatMoney(1478300.49)).toContain('1.478.300,49')
- })
-
- it('always shows two decimals, even on a round amount', () => {
- expect(formatMoney(1000)).toContain('1.000,00')
- })
-
- it('formats zero without falling back to an empty cell', () => {
- expect(formatMoney(0)).toContain('0,00')
- })
-})
-
describe('formatOrderDate', () => {
it('renders day, short month and year', () => {
// Mediodía UTC para que el día no se corra por el huso horario.
diff --git a/src/features/orders/utils/format.ts b/src/features/orders/utils/format.ts
index 0fab529..5c2ea06 100644
--- a/src/features/orders/utils/format.ts
+++ b/src/features/orders/utils/format.ts
@@ -1,13 +1,6 @@
// Formato de las celdas del listado. Vive acá y no en el componente para que
// cada regla se pueda probar sin montar la tabla.
-const MONEY = new Intl.NumberFormat('es-AR', {
- style: 'currency',
- currency: 'ARS',
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
-})
-
const DATE = new Intl.DateTimeFormat('es-AR', {
day: 'numeric',
month: 'short',
@@ -29,17 +22,6 @@ const SHORT_DATE = new Intl.DateTimeFormat('es-AR', {
month: 'short',
})
-/**
- * Importe de la orden.
- *
- * Con dos decimales siempre, aunque el diseño los omita: esta columna es plata
- * facturada y redondear $ 1.478.300,49 a $ 1.478.300 en pantalla es mostrar un
- * número que no es el de la orden.
- */
-export function formatMoney(amount: number): string {
- return MONEY.format(amount)
-}
-
/** "24 ago 2026" — la línea principal de la columna de fecha. */
export function formatOrderDate(iso: string): string {
return DATE.format(new Date(iso))
diff --git a/src/features/orders/utils/payment.test.ts b/src/features/orders/utils/payment.test.ts
index bffafbb..d0cab54 100644
--- a/src/features/orders/utils/payment.test.ts
+++ b/src/features/orders/utils/payment.test.ts
@@ -27,6 +27,7 @@ function order(overrides: Partial = {}): OrderDetail {
customerCity: null,
customerProvince: null,
status: 'paid',
+ requiresShipping: true,
version: null,
totalAmount: 1420000,
lines: [line(120000, 8), line(150000, 2), line(3313, 57)],
diff --git a/src/features/orders/utils/shipping.ts b/src/features/orders/utils/shipping.ts
index 10aef78..adf8e0d 100644
--- a/src/features/orders/utils/shipping.ts
+++ b/src/features/orders/utils/shipping.ts
@@ -68,6 +68,8 @@ export interface CreateOrderPayload {
customer_city: string
customer_province: string
customer_zip_code: string
+ /** Si la venta se despacha o la retira el cliente (TESIS-162). */
+ requires_shipping: boolean
items: {
product_id: number
warehouse_id: number
@@ -88,6 +90,7 @@ export function toCreateOrderPayload(
items: OrderDraftItem[],
origin: OrderDraftOrigin,
destination: OrderDraftDestination,
+ requiresShipping = true,
): CreateOrderPayload {
return {
order: {
@@ -97,6 +100,10 @@ export function toCreateOrderPayload(
customer_city: destination.city.trim(),
customer_province: destination.province,
customer_zip_code: destination.zipCode.trim(),
+ // El backend asume `true` si no viaja (TESIS-162); se manda igual, para
+ // que el cuerpo diga lo que el operador eligió y no lo que el default
+ // deduce.
+ requires_shipping: requiresShipping,
items: items.map((item) => ({
product_id: item.productId,
warehouse_id: origin.warehouseId,
diff --git a/src/features/reports/components/ReportMetrics/ReportMetrics.tsx b/src/features/reports/components/ReportMetrics/ReportMetrics.tsx
index 94a2f51..c5dbfef 100644
--- a/src/features/reports/components/ReportMetrics/ReportMetrics.tsx
+++ b/src/features/reports/components/ReportMetrics/ReportMetrics.tsx
@@ -4,10 +4,11 @@ import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined'
import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined'
import { StatCard } from 'shared/components'
import type { StatTrend } from 'shared/components'
+import { formatInteger } from 'shared/utils'
import { reportsCopy } from '../../content'
import type { TrendedValue } from '../../types'
-import { formatCompactMoney, formatInteger, formatPercent } from '../../utils/format'
+import { formatCompactMoney, formatPercent } from '../../utils/format'
import { MetricsGrid } from './ReportMetrics.styles'
import type { ReportMetricsProps } from './ReportMetrics.types'
diff --git a/src/features/reports/utils/format.test.ts b/src/features/reports/utils/format.test.ts
index 0638a5d..4f85ab7 100644
--- a/src/features/reports/utils/format.test.ts
+++ b/src/features/reports/utils/format.test.ts
@@ -1,22 +1,6 @@
import { describe, expect, it } from 'vitest'
-import {
- formatCompact,
- formatCompactMoney,
- formatInteger,
- formatPercent,
- formatUnits,
-} from './format'
-
-describe('formatInteger', () => {
- it('separates thousands with a dot, as es-AR does', () => {
- expect(formatInteger(124592)).toBe('124.592')
- })
-
- it('drops the decimals of a count', () => {
- expect(formatInteger(4200.7)).toBe('4.201')
- })
-})
+import { formatCompact, formatCompactMoney, formatPercent, formatUnits } from './format'
describe('formatCompact', () => {
it('leaves anything under a thousand as a plain integer', () => {
diff --git a/src/features/reports/utils/format.ts b/src/features/reports/utils/format.ts
index b349157..7f00d64 100644
--- a/src/features/reports/utils/format.ts
+++ b/src/features/reports/utils/format.ts
@@ -1,17 +1,13 @@
// Formato de los números de la pantalla de reportes. Locale fijo `es-AR`, como
// el resto de la app. Viven acá y no en los componentes para probarse solos.
-const INTEGER = new Intl.NumberFormat('es-AR', { maximumFractionDigits: 0 })
+import { formatInteger } from 'shared/utils'
+
const ONE_DECIMAL = new Intl.NumberFormat('es-AR', { maximumFractionDigits: 1 })
const THOUSAND = 1_000
const MILLION = 1_000_000
-/** «124.592» — unidades enteras con separador de miles. */
-export function formatInteger(value: number): string {
- return INTEGER.format(value)
-}
-
/**
* Cifra abreviada para tarjetas y ejes: «850», «7,5k», «4,2 MM».
*
@@ -24,7 +20,7 @@ export function formatCompact(value: number): string {
const magnitude = Math.abs(value)
if (magnitude >= MILLION) return `${ONE_DECIMAL.format(value / MILLION)} MM`
if (magnitude >= THOUSAND) return `${ONE_DECIMAL.format(value / THOUSAND)}k`
- return INTEGER.format(value)
+ return formatInteger(value)
}
/** «$4,2 MM» — importe abreviado, pegado al símbolo como en el diseño. */
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
new file mode 100644
index 0000000..79f28de
--- /dev/null
+++ b/src/features/shipments/api.ts
@@ -0,0 +1,91 @@
+import { client } from 'shared/api/client'
+import { fetchCount } from 'shared/api/count'
+
+import type { ShipmentFilters, ShipmentPage, ShipmentStatus, ShipmentSummary } from './types'
+
+// Frontera con Rails del listado de envíos. Único lugar de la feature que
+// conoce el endpoint y el snake_case.
+//
+// `GET /api/v1/shipments` existe paginado y filtrado desde TESIS-113, y hasta
+// TESIS-163 lo consumían sólo el KPI del panel y la resolución del envío de una
+// orden. La pantalla lo usa como lo que es: un listado.
+
+interface ApiCourier {
+ id: number
+ service_id: number
+ name: string
+}
+
+interface ApiShipmentSummary {
+ id: number
+ order_id: number
+ status: ShipmentStatus
+ tracking_number: string | null
+ shipping_cost: number | null
+ courier: ApiCourier | null
+ created_at: string
+}
+
+interface ApiList {
+ data: T[]
+ meta: { page: number; per_page: number; total: number }
+}
+
+function toCourier(courier: ApiCourier | null): ShipmentSummary['courier'] {
+ if (courier === null) return null
+
+ return { id: courier.id, serviceId: courier.service_id, name: courier.name }
+}
+
+function toShipment(shipment: ApiShipmentSummary): ShipmentSummary {
+ return {
+ id: shipment.id,
+ orderId: shipment.order_id,
+ status: shipment.status,
+ trackingNumber: shipment.tracking_number,
+ shippingCost: shipment.shipping_cost,
+ courier: toCourier(shipment.courier),
+ createdAt: shipment.created_at,
+ }
+}
+
+// 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. 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 } : {}),
+ }
+}
+
+export async function fetchShipmentPage(filters: ShipmentFilters): Promise {
+ const { data } = await client.get>('/shipments', {
+ params: toParams(filters),
+ })
+
+ return {
+ shipments: data.data.map(toShipment),
+ page: data.meta.page,
+ perPage: data.meta.per_page,
+ total: data.meta.total,
+ }
+}
+
+/**
+ * Cuántos envíos matchean un estado, sin traerlos.
+ *
+ * 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. Es el
+ * mismo `fetchCount` que usan el panel y el catálogo.
+ */
+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
new file mode 100644
index 0000000..d9f76a5
--- /dev/null
+++ b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx
@@ -0,0 +1,175 @@
+import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
+import { IconButton, Link, Tooltip, Typography } from '@mui/material'
+import { DataTable, StackedCell, StatusBadge } from 'shared/components'
+import type { DataTableColumn, StatusVariant } from 'shared/components'
+import { formatRoundMoney } from 'shared/utils'
+
+import { shipmentsCopy } from '../../content'
+import type { ShipmentStatus, ShipmentSummary } from '../../types'
+import { formatShipmentDate, formatShipmentTime } from '../../utils/format'
+
+import type { ShipmentsTableProps } from './ShipmentsTable.types'
+
+const { columns: columnCopy, cells, status: statusCopy, table } = shipmentsCopy
+
+// El tono de cada estado. Mapa explícito y no un `switch` con default: si el
+// backend suma un estado, el compilador marca este archivo en vez de pintarlo
+// en gris sin avisar. Es el mismo vocabulario que usa el detalle de la orden.
+const TONES: Record = {
+ pending: 'neutral',
+ ready_to_ship: 'info',
+ in_transit: 'info',
+ delivered: 'success',
+}
+
+/** Lo que todavía no existe no se dibuja vacío: se dice por qué falta. */
+function Missing({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function buildColumns(
+ onView: (shipment: ShipmentSummary) => void,
+): DataTableColumn[] {
+ return [
+ {
+ id: 'id',
+ header: columnCopy.id,
+ width: 110,
+ render: (shipment) => (
+ {cells.shipment(shipment.id)}
+ ),
+ },
+ {
+ id: 'order',
+ header: columnCopy.order,
+ width: 120,
+ render: (shipment) => (
+ // El detalle del envío vive en su orden: no hay pantalla propia, así
+ // que la columna enlaza ahí.
+ onView(shipment)}
+ sx={{ typography: 'dataMono', textAlign: 'left' }}
+ >
+ {cells.order(shipment.orderId)}
+
+ ),
+ },
+ {
+ id: 'courier',
+ header: columnCopy.courier,
+ width: 170,
+ render: (shipment) =>
+ shipment.courier === null ? (
+ {cells.noCourier}
+ ) : (
+ {shipment.courier.name}
+ ),
+ },
+ {
+ id: 'tracking',
+ header: columnCopy.tracking,
+ render: (shipment) =>
+ shipment.trackingNumber === null ? (
+ {cells.noTracking}
+ ) : (
+
+ {shipment.trackingNumber}
+
+ ),
+ },
+ {
+ id: 'cost',
+ header: columnCopy.cost,
+ width: 120,
+ align: 'right',
+ // Sin cotizar no cuesta cero: no se cotizó. Un 0 ahí afirmaría un envío
+ // gratis, que es un dato distinto de un envío sin precio.
+ render: (shipment) =>
+ shipment.shippingCost === null ? (
+ {cells.noCost}
+ ) : (
+ {formatRoundMoney(shipment.shippingCost)}
+ ),
+ },
+ {
+ id: 'status',
+ header: columnCopy.status,
+ width: 150,
+ render: (shipment) => (
+
+ ),
+ },
+ {
+ id: 'createdAt',
+ header: columnCopy.createdAt,
+ width: 124,
+ render: (shipment) => (
+
+ ),
+ },
+ {
+ // Una sola acción no justifica un kebab: abrirlo para elegir lo único que
+ // hay es un clic de más. El ojito va directo en la fila.
+ id: 'actions',
+ header: columnCopy.actions,
+ width: 72,
+ align: 'center',
+ render: (shipment) => (
+
+ onView(shipment)}
+ >
+
+
+
+ ),
+ },
+ ]
+}
+
+/**
+ * Tabla del listado de envíos.
+ *
+ * Presentacional: no consulta nada ni conoce los filtros. Recibe las filas y el
+ * callback, y lo único que decide es cómo se ve cada celda.
+ */
+export function ShipmentsTable({
+ shipments,
+ tabs,
+ activeTabId,
+ onTabChange,
+ pagination,
+ emptyMessage,
+ onView,
+}: ShipmentsTableProps) {
+ return (
+ shipment.id}
+ label={table.label}
+ tabs={tabs}
+ activeTabId={activeTabId}
+ onTabChange={onTabChange}
+ emptyMessage={emptyMessage}
+ pagination={pagination}
+ paginationLabels={{
+ previousLabel: shipmentsCopy.pagination.previous,
+ nextLabel: shipmentsCopy.pagination.next,
+ pageLabel: shipmentsCopy.pagination.page,
+ }}
+ />
+ )
+}
diff --git a/src/features/shipments/components/ShipmentsTable/ShipmentsTable.types.ts b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.types.ts
new file mode 100644
index 0000000..b778005
--- /dev/null
+++ b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.types.ts
@@ -0,0 +1,19 @@
+import type { DataTablePagination, DataTableTab } from 'shared/components'
+
+import type { ShipmentSummary } from '../../types'
+
+export interface ShipmentsTableProps {
+ shipments: ShipmentSummary[]
+ tabs: DataTableTab[]
+ 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/components/ShipmentsTable/index.ts b/src/features/shipments/components/ShipmentsTable/index.ts
new file mode 100644
index 0000000..ce3c1b2
--- /dev/null
+++ b/src/features/shipments/components/ShipmentsTable/index.ts
@@ -0,0 +1,2 @@
+export { ShipmentsTable } from './ShipmentsTable'
+export type { ShipmentsTableProps } from './ShipmentsTable.types'
diff --git a/src/features/shipments/content.ts b/src/features/shipments/content.ts
new file mode 100644
index 0000000..e8511ec
--- /dev/null
+++ b/src/features/shipments/content.ts
@@ -0,0 +1,65 @@
+// Copy centralizado de la feature — sin literales sueltos en el JSX.
+
+import { formatInteger } from 'shared/utils'
+
+export const shipmentsCopy = {
+ page: {
+ 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',
+ pending: 'Pendientes',
+ ready_to_ship: 'Listos para despachar',
+ in_transit: 'En tránsito',
+ delivered: 'Entregados',
+ },
+ columns: {
+ id: 'Envío',
+ order: 'Orden',
+ courier: 'Operador logístico',
+ tracking: 'Seguimiento',
+ cost: 'Costo',
+ status: 'Estado',
+ createdAt: 'Creado',
+ actions: 'Acciones',
+ },
+ cells: {
+ /** El courier se asigna recién al confirmar el despacho (TESIS-47). */
+ noCourier: 'Sin asignar',
+ noTracking: 'Pendiente de despacho',
+ /** Sin cotizar no cuesta cero: no se cotizó. */
+ noCost: 'Sin cotizar',
+ /** "Envío #31". */
+ shipment: (id: number) => `Envío #${id}`,
+ /** "Orden #8829". */
+ order: (id: number) => `Orden #${id}`,
+ },
+ status: {
+ pending: 'Pendiente',
+ ready_to_ship: 'Listo para despachar',
+ in_transit: 'En tránsito',
+ delivered: 'Entregado',
+ },
+ 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. */
+ viewFor: (id: number) => `Ver la orden del envío #${id}`,
+ },
+ pagination: {
+ previous: 'Página anterior',
+ next: 'Página siguiente',
+ page: (number: number) => `Página ${number}`,
+ summary: (from: number, to: number, total: number) =>
+ `Mostrando ${formatInteger(from)} a ${formatInteger(to)} de ${formatInteger(total)} ${total === 1 ? 'envío' : 'envíos'}`,
+ },
+} as const
diff --git a/src/features/shipments/hooks/useShipments.ts b/src/features/shipments/hooks/useShipments.ts
new file mode 100644
index 0000000..846eced
--- /dev/null
+++ b/src/features/shipments/hooks/useShipments.ts
@@ -0,0 +1,57 @@
+import { keepPreviousData, useQueries, useQuery } from '@tanstack/react-query'
+
+import { fetchShipmentCount, fetchShipmentPage } from '../api'
+import { shipmentKeys } from '../queryKeys'
+import type { ShipmentFilters, ShipmentPage, ShipmentStatus } from '../types'
+
+/**
+ * Las pestañas del listado, en el orden del ciclo de vida del envío, con el
+ * estado que filtra cada una. Una sola lista y no dos arrays paralelos: los
+ * contadores se piden en este mismo orden y se leen por índice.
+ */
+export const SHIPMENT_TABS = [
+ { id: 'all', status: undefined },
+ { id: 'pending', status: 'pending' },
+ { id: 'ready_to_ship', status: 'ready_to_ship' },
+ { id: 'in_transit', status: 'in_transit' },
+ { id: 'delivered', status: 'delivered' },
+] as const satisfies readonly { id: string; status: ShipmentStatus | undefined }[]
+
+export type ShipmentTabId = (typeof SHIPMENT_TABS)[number]['id']
+
+/**
+ * Una página del listado.
+ *
+ * `keepPreviousData` es lo que evita que cambiar de página vacíe la tabla: sin
+ * esto el cuerpo queda en blanco hasta que responde la API y la pantalla salta
+ * de alto en cada click.
+ */
+export function useShipmentPage(filters: ShipmentFilters) {
+ return useQuery({
+ queryKey: shipmentKeys.list(filters),
+ queryFn: () => fetchShipmentPage(filters),
+ placeholderData: keepPreviousData,
+ })
+}
+
+/**
+ * Los cinco contadores de las pestañas, en paralelo. Son consultas de una sola
+ * fila que leen nada más que el `meta.total`.
+ *
+ * Van en paralelo y no en una respuesta como los del catálogo (TESIS-162)
+ * porque la API de envíos no expone un endpoint de contadores: agregarlo es
+ * trabajo de backend y esta pantalla no lo necesita para existir. Si la
+ * cantidad de pestañas creciera, ahí sí convendría pedirlo.
+ *
+ * Que uno falle no voltea la pantalla: la tabla se ve igual y esa pestaña queda
+ * sin número.
+ */
+export function useShipmentCounts(search = '') {
+ return useQueries({
+ queries: SHIPMENT_TABS.map(({ status }) => ({
+ queryKey: shipmentKeys.count(status, search),
+ queryFn: () => fetchShipmentCount(status, search),
+ })),
+ combine: (results) => results.map((result) => result.data),
+ })
+}
diff --git a/src/features/shipments/index.ts b/src/features/shipments/index.ts
new file mode 100644
index 0000000..e538e33
--- /dev/null
+++ b/src/features/shipments/index.ts
@@ -0,0 +1 @@
+export { ShipmentsPage } from './pages/ShipmentsPage'
diff --git a/src/features/shipments/pages/ShipmentsPage.test.tsx b/src/features/shipments/pages/ShipmentsPage.test.tsx
new file mode 100644
index 0000000..3a50eaf
--- /dev/null
+++ b/src/features/shipments/pages/ShipmentsPage.test.tsx
@@ -0,0 +1,246 @@
+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'
+
+import { renderWithTheme } from '../../../test/renderWithTheme'
+import type * as shipmentHooks from '../hooks/useShipments'
+import { useShipmentCounts, useShipmentPage } from '../hooks/useShipments'
+import type { ShipmentPage, ShipmentSummary } from '../types'
+
+import { ShipmentsPage } from './ShipmentsPage'
+
+// `importOriginal` para conservar `SHIPMENT_TABS`, que la pantalla usa para
+// armar las pestañas: mockear el módulo entero la dejaría sin ninguna.
+vi.mock('../hooks/useShipments', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useShipmentPage: vi.fn(),
+ useShipmentCounts: vi.fn(),
+}))
+
+const DISPATCHED: ShipmentSummary = {
+ id: 31,
+ orderId: 8829,
+ status: 'in_transit',
+ trackingNumber: 'AND-9920-X8829-Z',
+ shippingCost: 58300,
+ courier: { id: 4, serviceId: 7, name: 'Andreani' },
+ createdAt: '2026-08-24T12:14:00Z',
+}
+
+// Recién abierto: los tres datos que completa el despacho todavía no están.
+const PENDING: ShipmentSummary = {
+ ...DISPATCHED,
+ id: 32,
+ orderId: 8830,
+ status: 'pending',
+ trackingNumber: null,
+ shippingCost: null,
+ courier: null,
+}
+
+function mockPage(rows: ShipmentSummary[], overrides: Partial = {}) {
+ vi.mocked(useShipmentPage).mockReturnValue({
+ data: { shipments: rows, page: 1, perPage: 20, total: rows.length, ...overrides },
+ isPending: false,
+ isError: false,
+ refetch: vi.fn(),
+ } 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(
+
+
+ } />
+ Detalle de la orden} />
+
+ ,
+ )
+}
+
+beforeEach(() => {
+ vi.mocked(useShipmentPage).mockReset()
+ vi.mocked(useShipmentCounts).mockReturnValue([2, 1, 0, 1, 0] as never)
+ mockPage([DISPATCHED, PENDING])
+})
+
+describe('ShipmentsPage', () => {
+ it('lists the shipments of the company', () => {
+ renderPage()
+
+ expect(screen.getByText('Envío #31')).toBeInTheDocument()
+ expect(screen.getByText('AND-9920-X8829-Z')).toBeInTheDocument()
+ expect(screen.getByText('Andreani')).toBeInTheDocument()
+ })
+
+ // Los tres los completa la confirmación del despacho (TESIS-47): hasta
+ // entonces la celda dice por qué falta, en vez de quedar vacía.
+ it('says what a shipment that was not dispatched yet is missing', () => {
+ renderPage()
+
+ expect(screen.getByText('Sin asignar')).toBeInTheDocument()
+ expect(screen.getByText('Pendiente de despacho')).toBeInTheDocument()
+ })
+
+ // Un envío sin cotizar no cuesta cero: no se cotizó.
+ it('does not claim a shipment without a quote is free', () => {
+ renderPage()
+
+ expect(screen.getByText('Sin cotizar')).toBeInTheDocument()
+ })
+
+ it('shows the quoted cost of the one that was dispatched', () => {
+ renderPage()
+
+ expect(screen.getByText(/58\.300/)).toBeInTheDocument()
+ })
+
+ // El detalle del envío vive en su orden: no hay pantalla propia.
+ it('takes each row to the order of its shipment', () => {
+ renderPage()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Orden #8829' }))
+
+ expect(screen.getByText('Detalle de la orden')).toBeInTheDocument()
+ })
+
+ // El ojito va directo en la fila: no hay un kebab que abrir para llegar a la
+ // única acción que tiene el listado.
+ it('opens the order from the eye of its row, with no menu in between', () => {
+ renderPage()
+
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Ver la orden del envío #31' }))
+
+ expect(screen.getByText('Detalle de la orden')).toBeInTheDocument()
+ })
+
+ it('counts each tab of the lifecycle', () => {
+ renderPage()
+
+ const tabs = screen.getByRole('tablist')
+ expect(within(tabs).getByRole('tab', { name: /Todos/ })).toHaveTextContent('2')
+ expect(within(tabs).getByRole('tab', { name: /Pendientes/ })).toHaveTextContent('1')
+ })
+
+ // Un contador que todavía no resolvió no muestra cero: sería un número falso
+ // durante el primer render.
+ it('leaves a tab without a number while its count travels', () => {
+ vi.mocked(useShipmentCounts).mockReturnValue([undefined, 1, 0, 1, 0] as never)
+ renderPage()
+
+ expect(
+ within(screen.getByRole('tablist')).getByRole('tab', { name: /Todos/ }),
+ ).not.toHaveTextContent('0')
+ })
+
+ it('says the range it is showing', () => {
+ renderPage()
+
+ expect(screen.getByText('Mostrando 1 a 2 de 2 envíos')).toBeInTheDocument()
+ })
+
+ // «Mostrando 1 a 0» es una frase rota.
+ it('starts the range at zero when there is nothing to show', () => {
+ mockPage([], { total: 0 })
+ renderPage()
+
+ 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({
+ data: undefined,
+ isPending: false,
+ isError: true,
+ refetch,
+ } as never)
+
+ renderPage()
+ fireEvent.click(screen.getByRole('button', { name: /Reintentar/ }))
+
+ expect(refetch).toHaveBeenCalled()
+ })
+})
diff --git a/src/features/shipments/pages/ShipmentsPage.tsx b/src/features/shipments/pages/ShipmentsPage.tsx
new file mode 100644
index 0000000..13f52db
--- /dev/null
+++ b/src/features/shipments/pages/ShipmentsPage.tsx
@@ -0,0 +1,162 @@
+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 { formatInteger } from 'shared/utils'
+
+import { ShipmentsTable } from '../components/ShipmentsTable'
+import { shipmentsCopy } from '../content'
+import { SHIPMENT_TABS, useShipmentCounts, useShipmentPage } from '../hooks/useShipments'
+import type { ShipmentTabId } from '../hooks/useShipments'
+import type { ShipmentSummary } from '../types'
+
+const { page: pageCopy, tabs: tabCopy, table, pagination } = shipmentsCopy
+
+const PER_PAGE = 20
+
+// El destino se declara acá: las rutas viven en `app/router`, capa que una
+// feature no puede importar (architecture.md §3.2).
+const orderPath = (id: number) => `/orders/${id}`
+
+/** El copy de cada pestaña, por su id. Las pestañas y su orden viven en `SHIPMENT_TABS`. */
+const TAB_LABELS: Record = {
+ all: tabCopy.all,
+ pending: tabCopy.pending,
+ ready_to_ship: tabCopy.ready_to_ship,
+ in_transit: tabCopy.in_transit,
+ delivered: tabCopy.delivered,
+}
+
+/**
+ * Listado de envíos: pestañas por estado y paginación contra
+ * `GET /api/v1/shipments` (TESIS-163).
+ *
+ * El endpoint existe paginado y filtrado desde TESIS-113 y hasta ahora lo
+ * consumían sólo el KPI del panel y la resolución del envío de una orden.
+ *
+ * No hay pantalla de detalle del envío y no es un olvido: el ciclo de vida, la
+ * bitácora y la etiqueta viven en el detalle de su orden (S08), que es donde el
+ * operador decide algo. Por eso cada fila lleva ahí.
+ */
+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,
+ 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 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],
+ // Un contador que todavía no resolvió no muestra cero: mostraría un número
+ // falso durante el primer render y luego saltaría al real.
+ count: counts[index] === undefined ? undefined : formatInteger(counts[index]),
+ }))
+
+ if (shipments.isPending) return
+ // `onRetry` y no un recargar de página: React Query ya tiene la consulta
+ // cacheada con sus filtros, así que reintentar es volver a dispararla.
+ if (shipments.isError) {
+ return (
+ void shipments.refetch()} />
+ )
+ }
+
+ const { shipments: rows, total } = shipments.data
+ const pageCount = Math.max(Math.ceil(total / PER_PAGE), 1)
+ // Con la tabla vacía el rango arranca en 0 y no en 1: «Mostrando 1 a 0» es
+ // una frase rota.
+ const from = total === 0 ? 0 : (page - 1) * PER_PAGE + 1
+ const to = Math.min(page * PER_PAGE, total)
+
+ function openOrder(shipment: ShipmentSummary) {
+ void navigate(orderPath(shipment.orderId))
+ }
+
+ return (
+
+
+ {/* `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: (
+
+
+
+ ),
+ },
+ }}
+ />
+
+
+
+
+
+ )
+}
diff --git a/src/features/shipments/queryKeys.ts b/src/features/shipments/queryKeys.ts
new file mode 100644
index 0000000..e12c466
--- /dev/null
+++ b/src/features/shipments/queryKeys.ts
@@ -0,0 +1,20 @@
+import type { ShipmentFilters, ShipmentStatus } from './types'
+
+// Factory de query keys de la feature (architecture.md §4.3).
+//
+// La raíz es `['shipments']`, la misma que usa el panel para su KPI y su
+// tarjeta de últimos envíos: despachar uno invalida ese dominio y refresca las
+// tres cosas sin que ninguna tenga que enterarse de las otras.
+export const shipmentKeys = {
+ all: ['shipments'] as const,
+ lists: () => [...shipmentKeys.all, 'list'] as const,
+ list: (filters: ShipmentFilters) => [...shipmentKeys.lists(), filters] as const,
+ counts: () => [...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.
+ // 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
new file mode 100644
index 0000000..8be5031
--- /dev/null
+++ b/src/features/shipments/types.ts
@@ -0,0 +1,48 @@
+// Tipos del dominio de envíos. Espejo de `GET /api/v1/shipments`, ya en
+// camelCase: ningún componente ve la forma cruda de Rails.
+
+/** Los cuatro estados del envío (`Shipment::STATUSES`), en el orden del ciclo. */
+export type ShipmentStatus = 'pending' | 'ready_to_ship' | 'in_transit' | 'delivered'
+
+/** El operador logístico: la integración de la empresa con el courier. */
+export interface Courier {
+ id: number
+ serviceId: number
+ name: string
+}
+
+/**
+ * Una fila del listado de envíos.
+ *
+ * `trackingNumber`, `courier` y `shippingCost` llegan vacíos mientras el envío
+ * está `pending`: los tres los completa la confirmación del despacho
+ * (TESIS-47). La fila tiene que tolerarlo sin dibujar huecos.
+ */
+export interface ShipmentSummary {
+ id: number
+ orderId: number
+ status: ShipmentStatus
+ trackingNumber: string | null
+ shippingCost: number | null
+ courier: Courier | null
+ createdAt: string
+}
+
+/** Cómo viene paginado el listado: el `meta` del backend, ya en camelCase. */
+export interface ShipmentPage {
+ shipments: ShipmentSummary[]
+ page: number
+ perPage: number
+ total: number
+}
+
+/** Lo que el listado manda como query string. */
+export interface ShipmentFilters {
+ page: number
+ perPage: number
+ 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
+}
diff --git a/src/features/shipments/utils/format.ts b/src/features/shipments/utils/format.ts
new file mode 100644
index 0000000..92b2d88
--- /dev/null
+++ b/src/features/shipments/utils/format.ts
@@ -0,0 +1,19 @@
+// Formateo de fechas del listado de envíos. Locale fijo `es-AR`, mismo criterio
+// que el resto del producto: la app está entera en español y no hay i18n.
+//
+// Propio y no importado de `features/orders`: una feature no importa de otra
+// (architecture.md §3.2). Si apareciera un tercer consumidor, el par sube a
+// `shared/utils` por la Regla de Dos.
+
+const DATE = new Intl.DateTimeFormat('es-AR', { day: '2-digit', month: 'short' })
+const TIME = new Intl.DateTimeFormat('es-AR', { hour: '2-digit', minute: '2-digit', hour12: false })
+
+/** "24 ago" — la línea principal de la celda de fecha. */
+export function formatShipmentDate(iso: string): string {
+ return DATE.format(new Date(iso))
+}
+
+/** "09:14" — la línea secundaria, en 24 horas. */
+export function formatShipmentTime(iso: string): string {
+ return TIME.format(new Date(iso))
+}
diff --git a/src/features/warehouses/content.ts b/src/features/warehouses/content.ts
index ae72aa2..bae6699 100644
--- a/src/features/warehouses/content.ts
+++ b/src/features/warehouses/content.ts
@@ -1,11 +1,5 @@
// Copy centralizado de la feature — sin literales sueltos en el JSX.
-const numberFormat = new Intl.NumberFormat('es-AR')
-
-export function formatUnits(value: number): string {
- return numberFormat.format(value)
-}
-
export const warehousesCopy = {
page: {
title: 'Depósitos',
diff --git a/src/features/warehouses/pages/WarehousesPage.tsx b/src/features/warehouses/pages/WarehousesPage.tsx
index 0bd9d69..f6cb8ee 100644
--- a/src/features/warehouses/pages/WarehousesPage.tsx
+++ b/src/features/warehouses/pages/WarehousesPage.tsx
@@ -6,10 +6,11 @@ import { useState } from 'react'
import { DataTable, ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components'
import type { DataTableAction, DataTableColumn } from 'shared/components'
import { notify } from 'shared/store'
+import { formatInteger } from 'shared/utils'
import { DeleteWarehouseDialog } from '../components/DeleteWarehouseDialog'
import { WarehouseFormModal } from '../components/WarehouseFormModal'
-import { formatUnits, warehousesCopy } from '../content'
+import { warehousesCopy } from '../content'
import {
RESTRICTED_STATUS,
useCreateWarehouse,
@@ -36,7 +37,7 @@ const COLUMNS: DataTableColumn[] = [
header: columns.storedUnits,
align: 'right',
width: 180,
- render: (warehouse) => formatUnits(warehouse.storedUnits),
+ render: (warehouse) => formatInteger(warehouse.storedUnits),
},
]
diff --git a/src/shared/api/activity.ts b/src/shared/api/activity.ts
new file mode 100644
index 0000000..450080f
--- /dev/null
+++ b/src/shared/api/activity.ts
@@ -0,0 +1,76 @@
+import { client } from './client'
+
+// Frontera con `GET /api/v1/activity` (TESIS-162): lo último que pasó en la
+// empresa, en una lista ya ordenada por fecha. Vive en `shared/` y no en una
+// feature porque quien la consume es el header, que está en `app/` y no puede
+// importar features (architecture.md §3.2).
+//
+// No hay estado de leído por usuario: el backend deriva la actividad de lo que
+// ya registró, y el badge cuenta lo del período, no «no leídas».
+
+/** Qué pasó. Unión cerrada: un tipo nuevo falla al compilar, no se pinta gris. */
+export type ActivityType = 'order_created' | 'shipment_dispatched' | 'event_failed'
+
+interface ApiActivityEntry {
+ id: string
+ type: ActivityType
+ occurred_at: string
+ order_id?: number
+ customer_name?: string
+ external_order_id?: string | null
+ total_amount?: number | null
+ shipment_id?: number
+ tracking_number?: string | null
+ courier?: string | null
+ failed_event_id?: number
+ event_type?: string
+ status?: string
+ integration?: string | null
+}
+
+/**
+ * Una entrada del feed. Los campos cambian según el tipo, que es lo que
+ * describe cada hecho: una venta tiene cliente, un despacho tiene courier.
+ * El componente discrimina por `type`.
+ */
+export interface ActivityEntry {
+ id: string
+ type: ActivityType
+ occurredAt: string
+ orderId: number | null
+ customerName: string | null
+ /** Null en una venta cargada a mano: es lo que la distingue de una de canal. */
+ externalOrderId: string | null
+ shipmentId: number | null
+ trackingNumber: string | null
+ courier: string | null
+ eventType: string | null
+ integration: string | null
+}
+
+function toEntry(entry: ApiActivityEntry): ActivityEntry {
+ return {
+ id: entry.id,
+ type: entry.type,
+ occurredAt: entry.occurred_at,
+ orderId: entry.order_id ?? null,
+ customerName: entry.customer_name ?? null,
+ externalOrderId: entry.external_order_id ?? null,
+ shipmentId: entry.shipment_id ?? null,
+ trackingNumber: entry.tracking_number ?? null,
+ courier: entry.courier ?? null,
+ eventType: entry.event_type ?? null,
+ integration: entry.integration ?? null,
+ }
+}
+
+export const activityKeys = {
+ all: ['activity'] as const,
+ list: () => [...activityKeys.all, 'list'] as const,
+}
+
+export async function fetchActivity(): Promise {
+ const { data } = await client.get<{ data: ApiActivityEntry[] }>('/activity')
+
+ return data.data.map(toEntry)
+}
diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts
index 6905698..146b8fc 100644
--- a/src/shared/api/index.ts
+++ b/src/shared/api/index.ts
@@ -1,3 +1,5 @@
+export { activityKeys, fetchActivity } from './activity'
+export type { ActivityEntry, ActivityType } from './activity'
export { client, LOGOUT_PATH } from './client'
export { fetchCount } from './count'
export type { CountFilters } from './count'
diff --git a/src/shared/components/ActivityPanel/ActivityPanel.styles.ts b/src/shared/components/ActivityPanel/ActivityPanel.styles.ts
new file mode 100644
index 0000000..616d5ef
--- /dev/null
+++ b/src/shared/components/ActivityPanel/ActivityPanel.styles.ts
@@ -0,0 +1,17 @@
+import { Box } from '@mui/material'
+import { styled } from '@mui/material/styles'
+
+// Tile del tipo de hecho, a la izquierda de cada fila. Misma geometría que los
+// tiles del panel: 32px con el ícono adentro, sobre superficie tonal del tema.
+export const EntryIcon = styled(Box)(({ theme }) => ({
+ width: 32,
+ height: 32,
+ flexShrink: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderRadius: theme.shape.borderRadius,
+ backgroundColor: theme.vars.palette.background.containerHighest,
+ color: theme.vars.palette.text.secondary,
+ '& svg': { display: 'block' },
+}))
diff --git a/src/shared/components/ActivityPanel/ActivityPanel.test.tsx b/src/shared/components/ActivityPanel/ActivityPanel.test.tsx
new file mode 100644
index 0000000..2a336ad
--- /dev/null
+++ b/src/shared/components/ActivityPanel/ActivityPanel.test.tsx
@@ -0,0 +1,120 @@
+import { screen } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { renderWithTheme } from '../../../test/renderWithTheme'
+import type { ActivityEntry } from '../../api/activity'
+import { useActivity } from '../../hooks/useActivity'
+
+import { ActivityPanel } from './ActivityPanel'
+
+vi.mock('../../hooks/useActivity', () => ({ useActivity: vi.fn() }))
+
+function entry(overrides: Partial = {}): ActivityEntry {
+ return {
+ id: 'order-8829',
+ type: 'order_created',
+ occurredAt: '2026-10-02T14:20:00Z',
+ orderId: 8829,
+ customerName: 'Marina Rodríguez',
+ externalOrderId: null,
+ shipmentId: null,
+ trackingNumber: null,
+ courier: null,
+ eventType: null,
+ integration: null,
+ ...overrides,
+ }
+}
+
+function mockFeed(entries: ActivityEntry[]) {
+ vi.mocked(useActivity).mockReturnValue({
+ data: entries,
+ isPending: false,
+ isError: false,
+ refetch: vi.fn(),
+ } as never)
+}
+
+// Las rutas llegan por props: `shared/` no puede importar el router.
+const PATHS = { order: (id: number) => `/orders/${id}` }
+
+function renderPanel(paths: typeof PATHS & { failedEvents?: string } = PATHS) {
+ const anchor = document.createElement('button')
+ document.body.appendChild(anchor)
+
+ return renderWithTheme(
+
+
+ ,
+ )
+}
+
+beforeEach(() => {
+ vi.mocked(useActivity).mockReset()
+ mockFeed([entry()])
+})
+
+describe('ActivityPanel', () => {
+ it('reads each fact of the feed as a sentence', () => {
+ renderPanel()
+
+ expect(screen.getByText('Nueva orden de Marina Rodríguez')).toBeInTheDocument()
+ })
+
+ it('tells a channel sale apart by its reference', () => {
+ mockFeed([entry({ externalOrderId: 'ML-1001' })])
+ renderPanel()
+
+ expect(screen.getByText(/ML-1001/)).toBeInTheDocument()
+ })
+
+ it('names the courier of a dispatch', () => {
+ mockFeed([entry({ type: 'shipment_dispatched', courier: 'Andreani' })])
+ renderPanel()
+
+ expect(screen.getByText('Envío despachado con Andreani')).toBeInTheDocument()
+ })
+
+ it('takes a sale to the detail of its order', () => {
+ renderPanel()
+
+ expect(screen.getByRole('link', { name: /Nueva orden/ })).toHaveAttribute(
+ 'href',
+ '/orders/8829',
+ )
+ })
+
+ // Una fila que informa y nada más no tiene que prometer una acción:
+ // `ListItemButton` se anuncia como botón aunque se lo monte sobre un `div`.
+ describe('a row with nowhere to go', () => {
+ // La pantalla de eventos fallidos no se le pasa al panel.
+ const failure = entry({ id: 'failure-1', type: 'event_failed', orderId: null })
+
+ it('is not announced as something that can be activated', () => {
+ mockFeed([failure])
+ renderPanel()
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
+ expect(screen.queryByRole('link')).not.toBeInTheDocument()
+ })
+
+ it('is still shown, with its sentence', () => {
+ mockFeed([failure])
+ renderPanel()
+
+ expect(screen.getByText('Falló un evento de integración')).toBeInTheDocument()
+ })
+
+ // Control negativo: con la pantalla cargada, esa misma fila sí enlaza.
+ it('becomes a link once the screen it needs exists', () => {
+ mockFeed([failure])
+ renderPanel({ ...PATHS, failedEvents: '/failed-events' })
+
+ expect(screen.getByRole('link', { name: /Falló un evento/ })).toHaveAttribute(
+ 'href',
+ '/failed-events',
+ )
+ })
+ })
+})
diff --git a/src/shared/components/ActivityPanel/ActivityPanel.tsx b/src/shared/components/ActivityPanel/ActivityPanel.tsx
new file mode 100644
index 0000000..eb95210
--- /dev/null
+++ b/src/shared/components/ActivityPanel/ActivityPanel.tsx
@@ -0,0 +1,142 @@
+import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
+import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
+import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined'
+import { Alert, Box, Button, Divider, List, ListItem, Popover, Typography } from '@mui/material'
+import type { ReactNode } from 'react'
+import { Link } from 'react-router-dom'
+
+import type { ActivityType } from '../../api/activity'
+import { useActivity } from '../../hooks/useActivity'
+import { LoadingSpinner } from '../LoadingSpinner'
+
+import { EntryIcon } from './ActivityPanel.styles'
+import type { ActivityPanelProps } from './ActivityPanel.types'
+import { activityContent } from './content'
+import { activityLabel, activityPath } from './entry'
+
+// Un ícono por tipo de hecho, para que la fila se reconozca sin leerla. Mapa
+// explícito y no un `switch` con default: si el backend suma un tipo, el
+// compilador marca este archivo.
+const ICONS: Record = {
+ order_created: ,
+ shipment_dispatched: ,
+ event_failed: ,
+}
+
+// Hora y día de cada hecho. Corto a propósito: el panel es una lista de un
+// vistazo, no una bitácora. La zona es la de quien mira, igual que el resto de
+// las fechas del producto.
+const WHEN = new Intl.DateTimeFormat('es-AR', {
+ day: '2-digit',
+ month: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+})
+
+/**
+ * Lo último que pasó en la empresa, colgando de la campanita (TESIS-163).
+ *
+ * Hasta ahora la campanita no hacía nada: `Header` nunca pasaba su handler y el
+ * badge sólo lo encendía la demo del design system.
+ *
+ * Vive en `shared/components` y no en una feature porque quien lo monta es el
+ * header, que está en `app/` y no puede importar features. Por eso las rutas de
+ * destino llegan por props en vez de importarse del router.
+ */
+// Fila alta y con aire: es un historial que se lee, no un menú que se recorre
+// con la vista.
+const ROW = {
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: 1.5,
+ py: 1.75,
+ px: 2,
+} as const
+
+const CLICKABLE = { cursor: 'pointer', color: 'inherit', textDecoration: 'none' } as const
+
+export function ActivityPanel({ anchorEl, onClose, paths }: ActivityPanelProps) {
+ const open = anchorEl !== null
+ // La consulta se dispara recién al abrirlo: el panel está montado en todas
+ // las pantallas y pedir el feed en cada visita sería un request por pantalla.
+ const activity = useActivity(open)
+
+ return (
+
+
+ {activityContent.title}
+
+
+
+ {activity.isPending ? (
+
+
+ {activityContent.loading}
+
+
+
+ ) : null}
+
+ {activity.isError ? (
+
+ void activity.refetch()}>
+ {activityContent.retry}
+
+ }
+ >
+ {activityContent.error}
+
+
+ ) : null}
+
+ {/* Vacío no es un error: es una empresa donde todavía no pasó nada. */}
+ {activity.data?.length === 0 ? (
+
+
+ {activityContent.empty}
+
+
+ ) : null}
+
+
+ {(activity.data ?? []).map((entry) => {
+ const to = activityPath(entry, paths)
+
+ return (
+ // Sin destino no es un botón: `ListItemButton` se anuncia como
+ // uno aunque se lo monte sobre un `div`, y una fila que informa y
+ // nada más no tiene que prometer una acción. Tampoco cierra el
+ // panel al tocarla, que era la otra mitad de la misma promesa.
+
+ {ICONS[entry.type]}
+
+ {activityLabel(entry)}
+
+ {WHEN.format(new Date(entry.occurredAt))}
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/shared/components/ActivityPanel/ActivityPanel.types.ts b/src/shared/components/ActivityPanel/ActivityPanel.types.ts
new file mode 100644
index 0000000..eadba43
--- /dev/null
+++ b/src/shared/components/ActivityPanel/ActivityPanel.types.ts
@@ -0,0 +1,14 @@
+export interface ActivityPanelProps {
+ /** El botón que lo abrió. `null` lo mantiene cerrado. */
+ anchorEl: HTMLElement | null
+ onClose: () => void
+ /**
+ * A dónde lleva cada fila. Llegan por props y no se importan del router:
+ * `shared/` no puede depender de `app/` (architecture.md §3.2).
+ */
+ paths: {
+ order: (id: number) => string
+ /** Todavía no existe esa pantalla (TESIS-147): sin ella, la fila no enlaza. */
+ failedEvents?: string
+ }
+}
diff --git a/src/shared/components/ActivityPanel/content.ts b/src/shared/components/ActivityPanel/content.ts
new file mode 100644
index 0000000..879c06c
--- /dev/null
+++ b/src/shared/components/ActivityPanel/content.ts
@@ -0,0 +1,24 @@
+// Copy del panel de actividad — sin literales sueltos en el JSX
+// (docs/guidelines/component-structure.md §4).
+export const activityContent = {
+ title: 'Actividad reciente',
+ loading: 'Buscando lo último que pasó…',
+ error: 'No pudimos cargar la actividad.',
+ retry: 'Reintentar',
+ /** Nada que mostrar no es un error: es una empresa que recién arranca. */
+ empty: 'Todavía no pasó nada por acá.',
+ close: 'Cerrar',
+ /** El texto de cada hecho. La API manda el dato; la frase se arma acá. */
+ orderCreated: {
+ manual: (customer: string) => `Nueva orden de ${customer}`,
+ channel: (customer: string, reference: string) => `Nueva venta de ${customer} · ${reference}`,
+ },
+ shipmentDispatched: {
+ withCourier: (courier: string) => `Envío despachado con ${courier}`,
+ withoutCourier: 'Envío despachado',
+ },
+ eventFailed: {
+ withIntegration: (integration: string) => `Falló un evento de ${integration}`,
+ withoutIntegration: 'Falló un evento de integración',
+ },
+} as const
diff --git a/src/shared/components/ActivityPanel/entry.ts b/src/shared/components/ActivityPanel/entry.ts
new file mode 100644
index 0000000..536fbca
--- /dev/null
+++ b/src/shared/components/ActivityPanel/entry.ts
@@ -0,0 +1,56 @@
+import type { ActivityEntry, ActivityType } from '../../api/activity'
+
+import { activityContent } from './content'
+
+// Cómo se lee y a dónde lleva cada hecho del feed. Fuera del componente para
+// poder probarlo sin montar el panel, mismo criterio que las reglas de las
+// features (TESIS-163).
+
+/**
+ * La frase de una entrada.
+ *
+ * Se arma acá y no en el backend: la API manda el hecho y sus datos, y el texto
+ * es decisión de la pantalla. Mapa explícito por tipo y no un `switch` con
+ * default: si el backend suma un tipo, el compilador marca este archivo en vez
+ * de dejarlo sin texto.
+ */
+const LABELS: Record string> = {
+ order_created: (entry) => {
+ const customer = entry.customerName ?? ''
+ return entry.externalOrderId === null
+ ? activityContent.orderCreated.manual(customer)
+ : activityContent.orderCreated.channel(customer, entry.externalOrderId)
+ },
+ shipment_dispatched: (entry) =>
+ entry.courier === null
+ ? activityContent.shipmentDispatched.withoutCourier
+ : activityContent.shipmentDispatched.withCourier(entry.courier),
+ event_failed: (entry) =>
+ entry.integration === null
+ ? activityContent.eventFailed.withoutIntegration
+ : activityContent.eventFailed.withIntegration(entry.integration),
+}
+
+export function activityLabel(entry: ActivityEntry): string {
+ return LABELS[entry.type](entry)
+}
+
+/**
+ * A dónde lleva la fila, o null si no hay a dónde.
+ *
+ * Una venta y un despacho llevan a la orden, porque el envío vive en su
+ * detalle. Las rutas llegan desde afuera: `shared/` no puede importar el router
+ * (architecture.md §3.2).
+ *
+ * `failedEvents` es opcional porque esa pantalla todavía no existe (la trae
+ * TESIS-147). Sin ella la fila se muestra igual y no enlaza, en vez de llevar a
+ * una ruta que el catch-all redirige al panel: el hecho importa aunque no haya
+ * a dónde ir a verlo. Cuando la pantalla entre, alcanza con pasar la ruta.
+ */
+export function activityPath(
+ entry: ActivityEntry,
+ paths: { order: (id: number) => string; failedEvents?: string },
+): string | null {
+ if (entry.type === 'event_failed') return paths.failedEvents ?? null
+ return entry.orderId === null ? null : paths.order(entry.orderId)
+}
diff --git a/src/shared/components/ActivityPanel/index.ts b/src/shared/components/ActivityPanel/index.ts
new file mode 100644
index 0000000..e0783b0
--- /dev/null
+++ b/src/shared/components/ActivityPanel/index.ts
@@ -0,0 +1,2 @@
+export { ActivityPanel } from './ActivityPanel'
+export type { ActivityPanelProps } from './ActivityPanel.types'
diff --git a/src/shared/components/TopNavBar/NavSearch.tsx b/src/shared/components/TopNavBar/NavSearch.tsx
deleted file mode 100644
index 38ce1f3..0000000
--- a/src/shared/components/TopNavBar/NavSearch.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import CloseIcon from '@mui/icons-material/Close'
-import SearchIcon from '@mui/icons-material/Search'
-import { IconButton, useMediaQuery, useTheme } from '@mui/material'
-import { useState } from 'react'
-
-import { topNavContent } from './content'
-import { SearchInput, SearchRoot } from './TopNavBar.styles'
-
-// Autocontenida: mantiene su propio valor y el toggle de colapso en mobile.
-// Sin `onSearch` — la card solo pide capturar el input; la búsqueda global
-// real se cablea cuando exista la feature correspondiente.
-export function NavSearch() {
- const theme = useTheme()
- const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
- const [mobileOpen, setMobileOpen] = useState(false)
- const [value, setValue] = useState('')
-
- if (isMobile && !mobileOpen) {
- return (
- setMobileOpen(true)}
- sx={{ color: 'text.secondary' }}
- >
-
-
- )
- }
-
- return (
-
-
- setValue(e.target.value)}
- />
- {isMobile ? (
- {
- setMobileOpen(false)
- setValue('')
- }}
- >
-
-
- ) : null}
-
- )
-}
diff --git a/src/shared/components/TopNavBar/TopNavBar.styles.ts b/src/shared/components/TopNavBar/TopNavBar.styles.ts
index 46e0e73..a64f8a5 100644
--- a/src/shared/components/TopNavBar/TopNavBar.styles.ts
+++ b/src/shared/components/TopNavBar/TopNavBar.styles.ts
@@ -1,4 +1,4 @@
-import { Box, InputBase } from '@mui/material'
+import { Box } from '@mui/material'
import { styled } from '@mui/material/styles'
import type { Theme } from '@mui/material/styles'
import { Link } from 'react-router-dom'
@@ -33,38 +33,6 @@ export const OrganizationName = styled(Box)(({ theme }) => ({
[theme.breakpoints.down('sm')]: { display: 'none' },
}))
-interface SearchRootProps {
- expanded?: boolean
-}
-
-const SEARCH_TRANSIENT_PROPS = new Set(['expanded'])
-
-export const SearchRoot = styled(Box, {
- shouldForwardProp: (prop) => !SEARCH_TRANSIENT_PROPS.has(prop as string),
-})(({ theme, expanded }) => ({
- display: 'flex',
- alignItems: 'center',
- gap: theme.spacing(1),
- height: 40,
- paddingInline: theme.spacing(1.5),
- borderRadius: 9999,
- border: `1px solid ${theme.vars.palette.divider}`,
- backgroundColor: theme.alpha(theme.vars.palette.text.primary, 0.04),
- width: expanded ? '100%' : 260,
- transition: theme.transitions.create(['border-color', 'box-shadow']),
- '&:focus-within': {
- borderColor: theme.vars.palette.primary.main,
- boxShadow: `0 0 0 3px ${theme.alpha(theme.vars.palette.primary.main, 0.2)}`,
- },
-}))
-
-export const SearchInput = styled(InputBase)(({ theme }) => ({
- flex: 1,
- color: theme.vars.palette.text.primary,
- ...theme.typography.bodyMd,
- '& ::placeholder': { color: theme.vars.palette.text.secondary, opacity: 1 },
-}))
-
export const userMenuPaperSx = (theme: Theme) => ({
mt: 1,
minWidth: 200,
diff --git a/src/shared/components/TopNavBar/TopNavBar.tsx b/src/shared/components/TopNavBar/TopNavBar.tsx
index a736cb6..3eab664 100644
--- a/src/shared/components/TopNavBar/TopNavBar.tsx
+++ b/src/shared/components/TopNavBar/TopNavBar.tsx
@@ -7,12 +7,11 @@ import { AppBar, Badge, Box, IconButton, Stack, Toolbar, Tooltip } from '@mui/ma
import { Link } from 'react-router-dom'
import { topNavContent } from './content'
-import { NavSearch } from './NavSearch'
import { BrandLink, OrganizationName } from './TopNavBar.styles'
import type { TopNavBarProps } from './TopNavBar.types'
import { UserMenu } from './UserMenu'
-// Shell de navegación global (brand + búsqueda + acciones + usuario). Fixed,
+// Shell de navegación global (brand + acciones + usuario). Fixed,
// full-width y por encima del drawer: intrínseco al componente vía el tema
// (MuiAppBar en app/theme/components/appBar.ts), no configurable por props.
export function TopNavBar({
@@ -25,8 +24,8 @@ export function TopNavBar({
onNotificationsClick,
settingsTo,
user,
- onProfileClick,
onLogout,
+ children,
}: TopNavBarProps) {
return (
@@ -48,8 +47,6 @@ export function TopNavBar({
-
-
{onToggleTheme ? (
onNotificationsClick?.(event.currentTarget)}
aria-label={topNavContent.notificationsAriaLabel}
sx={{ color: 'text.secondary' }}
>
@@ -102,8 +99,9 @@ export function TopNavBar({
) : null}
-
+
+ {children}
)
}
diff --git a/src/shared/components/TopNavBar/TopNavBar.types.ts b/src/shared/components/TopNavBar/TopNavBar.types.ts
index 0953e68..66c9981 100644
--- a/src/shared/components/TopNavBar/TopNavBar.types.ts
+++ b/src/shared/components/TopNavBar/TopNavBar.types.ts
@@ -1,3 +1,5 @@
+import type { ReactNode } from 'react'
+
export type TopNavThemeMode = 'light' | 'dark'
export interface TopNavUser {
@@ -24,11 +26,21 @@ export interface TopNavBarProps {
onToggleTheme?: () => void
/** Contador del badge de notificaciones. Badge oculto si es 0/undefined. */
notificationsCount?: number
- onNotificationsClick?: () => void
+ /**
+ * La campanita entrega el botón que la disparó: el panel de actividad se
+ * ancla a él (TESIS-163). Sin el elemento, quien escucha tendría que buscarlo
+ * en el DOM, que es justo lo que un `Popover` de MUI no necesita.
+ */
+ onNotificationsClick?: (anchor: HTMLElement) => void
/** Si se define, muestra el ícono de engranaje linkeando a esta ruta. */
settingsTo?: string
/** Datos del usuario. Sin esto, el avatar cae a un ícono genérico. */
user?: TopNavUser
- onProfileClick?: () => void
onLogout?: () => void
+ /**
+ * Lo que cuelga de la barra, anclado a ella: hoy el panel de actividad. Va
+ * como nodo y no como una prop por caso, porque `shared/components` no puede
+ * importar una feature y el contenido lo decide quien la monta.
+ */
+ children?: ReactNode
}
diff --git a/src/shared/components/TopNavBar/UserMenu.tsx b/src/shared/components/TopNavBar/UserMenu.tsx
index c69ad5f..6156591 100644
--- a/src/shared/components/TopNavBar/UserMenu.tsx
+++ b/src/shared/components/TopNavBar/UserMenu.tsx
@@ -1,6 +1,5 @@
import LogoutIcon from '@mui/icons-material/Logout'
import PersonIcon from '@mui/icons-material/Person'
-import PersonOutlineIcon from '@mui/icons-material/PersonOutline'
import {
Avatar,
Box,
@@ -28,13 +27,15 @@ function getInitials(name: string) {
interface UserMenuProps {
user?: TopNavUser
- onProfileClick?: () => void
onLogout?: () => void
}
// Avatar (imagen del usuario, con fallback nativo de MUI a los children si
-// falla la carga) + menú desplegable con "Mi perfil" / "Cerrar sesión".
-export function UserMenu({ user, onProfileClick, onLogout }: UserMenuProps) {
+// falla la carga) + menú desplegable con quién tiene la sesión y «Cerrar
+// sesión». Tenía además un «Mi perfil» que no llevaba a ningún lado —el header
+// nunca pasó su handler y no hay pantalla de perfil—: se sacó en TESIS-163, en
+// vez de dejar una acción que no hace nada.
+export function UserMenu({ user, onLogout }: UserMenuProps) {
const [anchorEl, setAnchorEl] = useState(null)
const open = Boolean(anchorEl)
const menuId = useId()
@@ -84,13 +85,6 @@ export function UserMenu({ user, onProfileClick, onLogout }: UserMenuProps) {
)}
-
-