From cbd114960de796d416621b05ef44df4a7e413b43 Mon Sep 17 00:00:00 2001
From: Tomas Martin
Date: Sun, 4 Oct 2026 17:41:01 -0300
Subject: [PATCH 01/10] feat: [TESIS-163] clean the header and the panel, model
pickups and show real stock figures
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
From the system audit of 04/10. Nine of its ten screen findings; the tenth, the
shipments listing, goes in its own PR (see below).
**The header.** The search box is gone: it never received an `onSearch`, it was
a decorative input that searched nothing. "Mi perfil" is gone too — the header
never passed its handler, there is no profile screen and no RF asks for one, so
the menu keeps who is signed in and "Cerrar sesión" instead of an action that
leads nowhere.
**The bell opens the activity feed.** It did nothing: `Header` never passed
`onNotificationsClick` and only the design system demo lit the badge. It now
opens a panel over `GET /activity` with the last orders, dispatches and events
that fell to the retry queue. It lives in `shared/components` because the
header is in `app/` and cannot import a feature, so its destinations arrive as
props.
**The panel.** The "Salud del sistema" KPI and the Integraciones card are gone.
RF-26 names "salud de los nodos de integración" among the four elements of the
panel, so the replacements are not filler: the "Eventos fallidos" KPI is the
same health said as a number somebody can act on, and the "Últimos envíos" card
reinforces the other dimension RF-26 names. Both come from endpoints that
already existed, so no mock anywhere.
**Pickup orders.** Step 2 now asks how the customer gets the order. With a
pickup, step 3 neither quotes nor dispatches, and the order detail and the
listing say so: an order that carries no shipment stopped looking like one that
is missing it.
**Real stock figures.** Committed, in transit and available to promise show
numbers, and **zero when the value is zero** — the figure exists and is zero.
Category, packaging and technical standard come from the API too, so every
"this does not exist in the API yet" notice is off the screen.
**The occupancy bar is real** where the warehouse declared its capacity, and
keeps comparing warehouses against each other where it did not.
**«Editar stock» edits stock.** It used to mount the whole product form. It is
a scope of the same modal and not a second one: the save, the `If-Match` and
the 412 are the same and duplicating them would mean two versions of the
delicate part.
**The catalogue resolves in three requests**, not six: the four tab counters
were one request each, reading `meta.total` of a page of one row.
Depends on proyecto-api#115 (TESIS-162).
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp
---
docs/guidelines/architecture.md | 18 +--
src/app/layout/Header.tsx | 15 +-
src/features/dashboard/api.ts | 51 ++++++-
.../IntegrationNodeList.styles.ts | 92 ------------
.../IntegrationNodeList.tsx | 105 --------------
.../IntegrationNodeList.types.ts | 11 --
.../components/IntegrationNodeList/index.ts | 2 -
.../NodeStatusIcon/NodeStatusIcon.styles.ts | 32 -----
.../NodeStatusIcon/NodeStatusIcon.tsx | 27 ----
.../NodeStatusIcon/NodeStatusIcon.types.ts | 11 --
.../components/NodeStatusIcon/index.ts | 2 -
.../RecentShipmentsCard.styles.ts | 93 +++++++++++++
.../RecentShipmentsCard.tsx | 103 ++++++++++++++
.../RecentShipmentsCard.types.ts | 8 ++
.../components/RecentShipmentsCard/index.ts | 2 +
.../WarehouseLoadCard/WarehouseLoadCard.tsx | 34 +++--
src/features/dashboard/content.ts | 68 ++++-----
.../dashboard/hooks/useInfraHealth.ts | 131 ------------------
.../dashboard/hooks/useLogisticsKpis.ts | 29 +++-
src/features/dashboard/hooks/useNow.ts | 21 ---
.../dashboard/hooks/useRecentShipments.ts | 39 ++++++
src/features/dashboard/index.ts | 5 +-
.../dashboard/pages/DashboardPage.test.tsx | 61 ++++++--
.../dashboard/pages/DashboardPage.tsx | 73 +++++-----
src/features/dashboard/queryKeys.ts | 11 ++
src/features/dashboard/types.ts | 36 +++--
src/features/dashboard/utils/capacity.test.ts | 44 +++++-
src/features/dashboard/utils/capacity.ts | 33 ++++-
src/features/inventory/api.ts | 61 ++++++--
.../EditProductModal.test.tsx | 56 +++++++-
.../EditProductModal/EditProductModal.tsx | 127 +++++++++--------
.../EditProductModal.types.ts | 16 +++
.../components/EditProductModal/index.ts | 1 +
src/features/inventory/content.ts | 12 +-
src/features/inventory/hooks/useInventory.ts | 40 +++---
.../inventory/pages/InventoryPage.tsx | 4 +-
.../pages/ProductDetailPage.test.tsx | 7 +
.../inventory/pages/ProductDetailPage.tsx | 81 ++++++-----
src/features/inventory/queryKeys.ts | 11 +-
src/features/inventory/types.ts | 42 ++++++
src/features/inventory/utils/conflict.test.ts | 10 +-
src/features/inventory/utils/payload.test.ts | 18 ++-
src/features/inventory/utils/stock.test.ts | 3 +-
src/features/orders/api.test.ts | 4 +
src/features/orders/api.ts | 4 +
.../components/OrdersTable/CarrierCell.tsx | 10 +-
.../OrdersTable/OrdersTable.test.tsx | 9 ++
.../components/OrdersTable/OrdersTable.tsx | 2 +-
.../ShipmentLifecycleCard.tsx | 8 +-
.../ShipmentLifecycleCard.types.ts | 2 +
.../components/ShipmentStateMessage.tsx | 9 +-
src/features/orders/content.ts | 24 ++++
.../hooks/useConfirmDraftOrder.test.tsx | 1 +
.../orders/hooks/useConfirmDraftOrder.ts | 9 +-
.../orders/pages/CarrierStepPage.test.tsx | 57 ++++++++
src/features/orders/pages/CarrierStepPage.tsx | 67 ++++++---
.../orders/pages/OrderDetailPage.test.tsx | 20 +++
src/features/orders/pages/OrderDetailPage.tsx | 1 +
.../orders/pages/OrderEditPage.test.tsx | 1 +
.../orders/pages/ShippingStepPage.test.tsx | 5 +-
.../orders/pages/ShippingStepPage.tsx | 48 ++++++-
src/features/orders/types.ts | 8 ++
src/features/orders/utils/payment.test.ts | 1 +
src/features/orders/utils/shipping.ts | 7 +
src/shared/api/activity.ts | 76 ++++++++++
src/shared/api/index.ts | 2 +
.../ActivityPanel/ActivityPanel.tsx | 117 ++++++++++++++++
.../ActivityPanel/ActivityPanel.types.ts | 14 ++
.../components/ActivityPanel/content.ts | 24 ++++
src/shared/components/ActivityPanel/entry.ts | 56 ++++++++
src/shared/components/ActivityPanel/index.ts | 2 +
src/shared/components/TopNavBar/NavSearch.tsx | 54 --------
.../components/TopNavBar/TopNavBar.styles.ts | 34 +----
src/shared/components/TopNavBar/TopNavBar.tsx | 12 +-
.../components/TopNavBar/TopNavBar.types.ts | 16 ++-
src/shared/components/TopNavBar/UserMenu.tsx | 16 +--
src/shared/components/TopNavBar/content.ts | 5 -
src/shared/components/index.ts | 2 +
src/shared/hooks/useActivity.ts | 20 +++
src/shared/store/orderDraftStore.ts | 16 ++-
80 files changed, 1566 insertions(+), 843 deletions(-)
delete mode 100644 src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.styles.ts
delete mode 100644 src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.tsx
delete mode 100644 src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.types.ts
delete mode 100644 src/features/dashboard/components/IntegrationNodeList/index.ts
delete mode 100644 src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.styles.ts
delete mode 100644 src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.tsx
delete mode 100644 src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.types.ts
delete mode 100644 src/features/dashboard/components/NodeStatusIcon/index.ts
create mode 100644 src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.styles.ts
create mode 100644 src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.tsx
create mode 100644 src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.types.ts
create mode 100644 src/features/dashboard/components/RecentShipmentsCard/index.ts
delete mode 100644 src/features/dashboard/hooks/useInfraHealth.ts
delete mode 100644 src/features/dashboard/hooks/useNow.ts
create mode 100644 src/features/dashboard/hooks/useRecentShipments.ts
create mode 100644 src/shared/api/activity.ts
create mode 100644 src/shared/components/ActivityPanel/ActivityPanel.tsx
create mode 100644 src/shared/components/ActivityPanel/ActivityPanel.types.ts
create mode 100644 src/shared/components/ActivityPanel/content.ts
create mode 100644 src/shared/components/ActivityPanel/entry.ts
create mode 100644 src/shared/components/ActivityPanel/index.ts
delete mode 100644 src/shared/components/TopNavBar/NavSearch.tsx
create mode 100644 src/shared/hooks/useActivity.ts
diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md
index 02c5c52..5f881d4 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,14 +308,14 @@ features/[nombre]/
**Features existentes:**
-| Feature | Contenido |
-| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `dashboard` | Panel de operación (`/dashboard`) y la sección de Integraciones (`/integrations`, detrás del feature flag `integrations` del tenant). Comparten `useInfraHealth` e `IntegrationNodeList`. El panel suma la tarjeta de alertas de inventario, la carga por depósito y la tabla de órdenes recientes (`useInventoryAlerts`, `WarehouseLoadCard`, `useRecentOrders`, `RecentOrdersTable`) |
-| `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks |
-| `home` | Landing de la app (`/`) |
-| `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` |
-| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío, 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): métricas del período, curva de despacho, nivel de servicio por operador y anomalías recientes. **Sin endpoint de agregados todavía**: la pantalla muestra el dataset de muestra del diseño y lo dice junto al título (ver `api.ts`) |
+| Feature | Contenido |
+| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `dashboard` | Panel de operación (`/dashboard`, S03). KPIs de órdenes pendientes, envíos activos, **eventos fallidos** 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 endpoints que ya existían: `/failed-events` 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` |
+| `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; `/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): métricas del período, curva de despacho, nivel de servicio por operador y anomalías recientes. **Sin endpoint de agregados todavía**: la pantalla muestra el dataset de muestra del diseño y lo dice junto al título (ver `api.ts`) |
**`orders` — piezas y por qué:**
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/features/dashboard/api.ts b/src/features/dashboard/api.ts
index 4dda81f..6c40758 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
}
/**
@@ -93,6 +94,54 @@ export async function fetchWarehouseLoads(): Promise {
id: warehouse.id,
name: warehouse.name,
storedUnits: warehouse.stored_units,
+ capacity: warehouse.capacity,
+ }))
+}
+
+/**
+ * Los eventos que todavía esperan en la cola de reintentos (TESIS-163).
+ *
+ * Reemplaza al KPI de «Salud del sistema», que mostraba un porcentaje derivado
+ * de la frescura de los nodos. Es la misma salud dicha de una forma sobre la
+ * que se puede actuar: cuántas cosas se cayeron y siguen sin resolverse.
+ */
+export const PENDING_FAILED_EVENT_STATUS = 'pending'
+
+export function fetchPendingFailedEventCount(): Promise {
+ return fetchCount('/failed-events', { status: PENDING_FAILED_EVENT_STATUS })
+}
+
+/** 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/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..32d71c0
--- /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..5c3173e 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.
*/
@@ -63,13 +63,25 @@ export function WarehouseLoadCard({
{warehouse.name}
- {copy.units(warehouse.storedUnits)}
+
+ {warehouse.measuredAgainstCapacity
+ ? copy.occupancy(warehouse.share)
+ : copy.units(warehouse.storedUnits)}
+
))}
diff --git a/src/features/dashboard/content.ts b/src/features/dashboard/content.ts
index 468e037..fc5b463 100644
--- a/src/features/dashboard/content.ts
+++ b/src/features/dashboard/content.ts
@@ -22,6 +22,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.
+ failedEvents: {
+ label: 'Eventos fallidos',
+ tag: 'Revisar',
+ note: (pending: number) =>
+ pending === 1
+ ? '1 evento esperando reintento'
+ : `${UNITS_FORMAT.format(pending)} eventos esperando reintento`,
+ calmNote: 'Sin eventos pendientes de reintento',
+ },
// 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`.
@@ -54,10 +65,16 @@ 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_FORMAT.format(units)} ${units === 1 ? 'unidad guardada' : 'unidades guardadas'}`,
units: (units: number) => `${UNITS_FORMAT.format(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'}`,
+ barLabelWithCapacity: (name: string, units: number, capacity: number) =>
+ `${name}: ${UNITS_FORMAT.format(units)} de ${UNITS_FORMAT.format(capacity)} unidades de capacidad`,
empty: 'La empresa no tiene depósitos cargados.',
},
// Tabla de órdenes recientes del panel (TESIS-56). Vocabulario y orden de
@@ -85,36 +102,25 @@ 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}`,
+ // 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/useLogisticsKpis.ts b/src/features/dashboard/hooks/useLogisticsKpis.ts
index 47ebc44..c5aaf4b 100644
--- a/src/features/dashboard/hooks/useLogisticsKpis.ts
+++ b/src/features/dashboard/hooks/useLogisticsKpis.ts
@@ -4,10 +4,12 @@ import { useCallback } from 'react'
import {
ACTIVE_SHIPMENT_STATUS,
fetchActiveShipmentCount,
+ fetchPendingFailedEventCount,
fetchPendingOrderCount,
+ PENDING_FAILED_EVENT_STATUS,
PENDING_ORDER_STATUS,
} from '../api'
-import { orderKeys, shipmentKeys } from '../queryKeys'
+import { failedEventKeys, orderKeys, shipmentKeys } from '../queryKeys'
export interface KpiState {
/** El total que devolvió la API, o `undefined` mientras carga o si falló. */
@@ -19,7 +21,14 @@ export interface KpiState {
export interface LogisticsKpis {
pendingOrders: KpiState
activeShipments: KpiState
- /** Alguna de las dos consultas falló. */
+ /**
+ * Los eventos que siguen esperando en la cola de reintentos (TESIS-163).
+ * Reemplaza al KPI de «Salud del sistema», que mostraba un porcentaje
+ * derivado de la frescura de los nodos: es la misma salud dicha como un
+ * número sobre el que se puede actuar.
+ */
+ failedEvents: KpiState
+ /** Alguna de las consultas falló. */
isError: boolean
refetch: () => void
}
@@ -45,15 +54,22 @@ export function useLogisticsKpis(): LogisticsKpis {
queryFn: fetchActiveShipmentCount,
})
+ const failedEvents = useQuery({
+ queryKey: failedEventKeys.kpi(PENDING_FAILED_EVENT_STATUS),
+ queryFn: fetchPendingFailedEventCount,
+ })
+
const { refetch: refetchPendingOrders } = pendingOrders
const { refetch: refetchActiveShipments } = activeShipments
+ const { refetch: refetchFailedEvents } = failedEvents
// Envuelto: `refetch` de React Query recibe `RefetchOptions`, y cablearlo
// directo a un `onClick` le pasaría el MouseEvent como opciones.
const refetch = useCallback(() => {
void refetchPendingOrders()
void refetchActiveShipments()
- }, [refetchPendingOrders, refetchActiveShipments])
+ void refetchFailedEvents()
+ }, [refetchPendingOrders, refetchActiveShipments, refetchFailedEvents])
return {
pendingOrders: {
@@ -66,7 +82,12 @@ export function useLogisticsKpis(): LogisticsKpis {
isLoading: activeShipments.isLoading,
isError: activeShipments.isError,
},
- isError: pendingOrders.isError || activeShipments.isError,
+ failedEvents: {
+ value: failedEvents.data,
+ isLoading: failedEvents.isLoading,
+ isError: failedEvents.isError,
+ },
+ isError: pendingOrders.isError || activeShipments.isError || failedEvents.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 3db38c2..d4d1353 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
@@ -84,19 +98,16 @@ beforeEach(() => {
vi.mocked(useLogisticsKpis).mockReturnValue({
pendingOrders: { value: 12, isLoading: false, isError: false },
activeShipments: { value: 5, isLoading: false, isError: false },
+ failedEvents: { value: 0, isLoading: false, isError: false },
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()
})
@@ -223,12 +234,34 @@ 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()
+ })
+
+ 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..98b310a 100644
--- a/src/features/dashboard/pages/DashboardPage.tsx
+++ b/src/features/dashboard/pages/DashboardPage.tsx
@@ -1,22 +1,22 @@
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
-import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined'
+import ReportGmailerrorredOutlinedIcon from '@mui/icons-material/ReportGmailerrorredOutlined'
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 { 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 failedEventsCopy = metrics.failedEvents
const alertsCopy = metrics.inventoryAlerts
// Destino del click en la tarjeta de alertas. Las rutas se registran en
@@ -70,21 +70,11 @@ export function DashboardPage() {
const {
pendingOrders,
activeShipments,
+ failedEvents,
isError: isKpisError,
refetch: refetchKpis,
} = useLogisticsKpis()
- const {
- nodes,
- reportingNodes,
- onlineNodes,
- healthPercentage,
- healthTone,
- isLoading: isInfraLoading,
- isError: isInfraError,
- refetch: refetchInfra,
- } = useInfraHealth()
-
const {
alerts,
breakdown,
@@ -102,13 +92,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,13 +115,10 @@ 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)}%`
+ // Mismo criterio que las alertas de inventario: el tono se enciende sólo si
+ // hay algo que revisar. `undefined` no es cero, así que mientras el número
+ // viaja la tarjeta no afirma que la cola está limpia.
+ const hasFailures = failedEvents.value !== undefined && failedEvents.value > 0
return (
@@ -170,11 +164,22 @@ export function DashboardPage() {
/>
+ {/* 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. */}
}
- tone={healthTone}
+ label={failedEventsCopy.label}
+ value={formatCount(failedEvents.value)}
+ loading={failedEvents.isLoading}
+ icon={}
+ tone={hasFailures ? 'warning' : 'neutral'}
+ tag={hasFailures ? failedEventsCopy.tag : undefined}
+ tagTone="warning"
+ note={
+ failedEvents.value === undefined
+ ? undefined
+ : failedEventsCopy.note(failedEvents.value)
+ }
/>
@@ -203,7 +208,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 +217,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,
+}
+
+// Cuelga de `['failed-events']`, la raíz del recurso que cuenta, por el mismo
+// motivo que los otros KPIs: reintentar o descartar un evento refresca el
+// número sin que el panel tenga que enterarse.
+export const failedEventKeys = {
+ all: ['failed-events'] as const,
+ kpi: (status: string) => ['failed-events', 'kpi', status] 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..78fe724 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,38 @@ 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,
+ })
+ })
+})
diff --git a/src/features/dashboard/utils/capacity.ts b/src/features/dashboard/utils/capacity.ts
index 13cd728..d02824e 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,19 @@ 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,
}))
}
+function shareOf(warehouse: WarehouseLoad, busiest: number): number {
+ if (warehouse.capacity !== null) {
+ 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/inventory/api.ts b/src/features/inventory/api.ts
index e714041..8179f31 100644
--- a/src/features/inventory/api.ts
+++ b/src/features/inventory/api.ts
@@ -1,11 +1,13 @@
import { client } from 'shared/api/client'
import type {
+ CatalogCounts,
CreateProductPayload,
LinkProductPayload,
LinkProductResult,
Product,
ProductMapping,
+ ProductCategory,
ProductFilters,
ProductPage,
ProductSummary,
@@ -31,11 +33,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
}
@@ -57,9 +61,17 @@ interface ApiProduct {
sku: string
name: string
description: string | null
+ category: ProductCategory | null
+ packaging: string | null
+ technical_standard: string | null
weight: number
dimensions: string | null
updated_at: string
+ committed_quantity: number
+ on_hand_quantity: number
+ available_to_promise: number
+ in_transit_quantity: number
+ committed_by_warehouse: { warehouse_id: number; name: string; quantity: number }[]
stocks: ApiStock[]
}
@@ -69,7 +81,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
@@ -79,18 +96,34 @@ 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,
+ packaging: product.packaging,
+ technicalStandard: product.technical_standard,
weight: product.weight,
dimensions: product.dimensions,
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),
})),
}
@@ -112,10 +145,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 }),
@@ -136,17 +171,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 0a19cc5..406e9c2 100644
--- a/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx
+++ b/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx
@@ -12,13 +12,26 @@ 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',
+ committed: 0,
+ onHand: 10,
+ availableToPromise: 10,
+ inTransit: 0,
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,
+ },
},
],
updatedAt: '2026-08-30T12:00:00.000Z',
@@ -44,6 +57,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 60752d1..6842a09 100644
--- a/src/features/inventory/components/EditProductModal/EditProductModal.tsx
+++ b/src/features/inventory/components/EditProductModal/EditProductModal.tsx
@@ -58,6 +58,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.
@@ -70,7 +77,9 @@ export function EditProductModal({
onClose,
submitting = false,
conflict,
+ scope = 'product',
}: EditProductModalProps) {
+ const onlyStock = scope === 'stock'
const [menuAnchor, setMenuAnchor] = useState(null)
const {
@@ -122,7 +131,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 : (
+
+
+
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
-
-
+
+
+
+
+
+ )}
count === 1 ? '1 depósito con stock asignado' : `${count} depósitos con stock asignado`,
empty: 'Este producto no tiene stock asignado en ningún depósito.',
- pending: 'Comprometido y en tránsito esperan que la API los modele.',
+ // 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',
@@ -148,6 +144,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: {
diff --git a/src/features/inventory/hooks/useInventory.ts b/src/features/inventory/hooks/useInventory.ts
index 7e2c47e..22aea9a 100644
--- a/src/features/inventory/hooks/useInventory.ts
+++ b/src/features/inventory/hooks/useInventory.ts
@@ -1,25 +1,21 @@
-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 {
createProduct,
fetchProduct,
deleteProduct,
- fetchProductCount,
+ fetchProductCounts,
fetchProductPage,
fetchWarehouses,
updateProduct,
} from '../api'
import { inventoryKeys } from '../queryKeys'
import type {
+ CatalogCounts,
CreateProductPayload,
Product,
+ ProductCategory,
ProductFilters,
ProductPage,
StockStatus,
@@ -57,21 +53,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 fc0ac6b..579060e 100644
--- a/src/features/inventory/pages/InventoryPage.tsx
+++ b/src/features/inventory/pages/InventoryPage.tsx
@@ -156,12 +156,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 : formatUnits(counts.data[id]),
}))
return (
diff --git a/src/features/inventory/pages/ProductDetailPage.test.tsx b/src/features/inventory/pages/ProductDetailPage.test.tsx
index 4ece89f..88ef56b 100644
--- a/src/features/inventory/pages/ProductDetailPage.test.tsx
+++ b/src/features/inventory/pages/ProductDetailPage.test.tsx
@@ -21,8 +21,15 @@ const PRODUCT: Product = {
sku: 'NOR-007',
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: [],
updatedAt: '2026-09-01T10:00:00Z',
version: 'W/"1"',
diff --git a/src/features/inventory/pages/ProductDetailPage.tsx b/src/features/inventory/pages/ProductDetailPage.tsx
index 1e058cc..faa81fa 100644
--- a/src/features/inventory/pages/ProductDetailPage.tsx
+++ b/src/features/inventory/pages/ProductDetailPage.tsx
@@ -8,6 +8,7 @@ import { ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components'
import { notify, useTenantFeature } from 'shared/store'
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'
@@ -50,11 +51,16 @@ function buildSpecs(product: Product): ProductSpec[] {
const hasDimensions = product.dimensions !== null && product.dimensions !== ''
return [
- // `products` no tiene columna de categoría (las cuatro de Product::CATEGORIES
- // viven en el backend pero no llegan en el serializer), ni empaque, ni norma
- // técnica. 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: 'category', label: specsCopy.fields.category, value: specsCopy.unknown, unknown: true },
+ // Los tres salen de la API desde TESIS-150 (categoría) y TESIS-162
+ // (empaque y norma). 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,
+ value: product.category ?? specsCopy.unknown,
+ unknown: product.category === null,
+ },
{
id: 'weight',
label: specsCopy.fields.weight,
@@ -74,7 +80,12 @@ function buildSpecs(product: Product): ProductSpec[] {
mono: true,
unknown: !hasDimensions,
},
- { 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,
+ },
]
}
@@ -83,7 +94,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,
@@ -94,34 +110,34 @@ function buildSecondarySpecs(product: Product): ProductSpec[] {
}
/**
- * Desglose por estado de reserva.
+ * Desglose por estado de reserva, con los números que calcula la API desde
+ * TESIS-162.
*
- * Las tres cubetas van sin dato: la API devuelve un solo número por depósito
- * (las unidades en depósito) y no modela reservas ni mercadería en tránsito.
- * Ver el encabezado de `utils/stock.ts`.
+ * 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.
*/
-function buildBuckets(): StockBucket[] {
+function buildBuckets(product: Product): StockBucket[] {
return [
{
id: 'committed',
label: masterCopy.buckets.committed,
- icon: ,
- value: specsCopy.unknown,
- unknown: true,
+ icon: ,
+ value: formatUnits(product.committed),
},
{
id: 'inTransit',
label: masterCopy.buckets.inTransit,
- icon: ,
- value: specsCopy.unknown,
- unknown: true,
+ icon: ,
+ value: formatUnits(product.inTransit),
},
{
id: 'availableToPromise',
label: masterCopy.buckets.availableToPromise,
- icon: ,
- value: specsCopy.unknown,
- unknown: true,
+ icon: ,
+ value: formatUnits(product.availableToPromise),
accent: true,
},
]
@@ -136,7 +152,7 @@ function buildRows(product: Product): WarehouseDistributionRow[] {
id: stock.warehouseId,
name: stock.warehouse.name,
location: stock.warehouse.address,
- committed: specsCopy.unknown,
+ committed: formatUnits(stock.committed),
inTransit: specsCopy.unknown,
onHand: formatUnits(stock.quantity),
statusLabel: status[level],
@@ -166,7 +182,9 @@ export function ProductDetailPage() {
const { pathname, state } = useLocation()
const navigate = useNavigate()
const abrirEdicion = typeof state === 'object' && state !== null && 'edit' in state
- const [editing, setEditing] = useState(abrirEdicion)
+ // Qué alcance abre el modal, o `null` si está cerrado: «Editar producto»
+ // abre el formulario entero y «Editar stock» sólo las cantidades.
+ const [editing, setEditing] = useState(abrirEdicion ? 'product' : null)
const integrationsEnabled = useTenantFeature('integrations')
// La intención se consume una sola vez. `location.state` vive en
@@ -213,7 +231,7 @@ export function ProductDetailPage() {
updateMutation.mutate(payload, {
onSuccess: () => {
notify(page.saved(name), 'success')
- setEditing(false)
+ setEditing(null)
setBaseline(undefined)
},
onError: () => void product.refetch(),
@@ -259,26 +277,24 @@ export function ProductDetailPage() {
statusLabel={status[level]}
statusVariant={STOCK_LEVEL_STATUS[level]}
catalogPath={CATALOG_PATH}
- onEdit={() => setEditing(true)}
+ onEdit={() => setEditing('product')}
/>
setEditing(true)}
+ buckets={buildBuckets(product.data)}
+ onEditStock={() => setEditing('stock')}
/>
@@ -301,7 +317,8 @@ 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,
mappings: (productId: number) => [...inventoryKeys.product(productId), 'mappings'] as const,
diff --git a/src/features/inventory/types.ts b/src/features/inventory/types.ts
index f7a12b6..3e5ea02 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
}
@@ -86,8 +94,31 @@ 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
+ /**
+ * 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
+ /**
+ * Unidades que salieron de un depósito y todavía no llegaron a otro. Ya venía
+ * en el serializer del detalle (`in_transit_quantity`); lo que faltaba era
+ * mapearlo. El desglose **por depósito** lo trae TESIS-144.
+ */
+ inTransit: number
stocks: ProductStock[]
updatedAt: string
/**
@@ -164,3 +195,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 bde062a..25be4ea 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 },
}
}
@@ -27,8 +28,15 @@ 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',
+ committed: 0,
+ onHand: 10,
+ availableToPromise: 10,
+ inTransit: 0,
stocks: [stock(1, 10, 'CD Ezeiza')],
updatedAt: '2026-08-30T12:00:00.000Z',
version: '"abc"',
diff --git a/src/features/inventory/utils/payload.test.ts b/src/features/inventory/utils/payload.test.ts
index efa363a..be35d77 100644
--- a/src/features/inventory/utils/payload.test.ts
+++ b/src/features/inventory/utils/payload.test.ts
@@ -44,13 +44,26 @@ 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',
+ committed: 0,
+ onHand: 10,
+ availableToPromise: 10,
+ inTransit: 0,
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,
+ },
},
],
updatedAt: '2026-08-30T12:00:00.000Z',
@@ -119,7 +132,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 },
},
],
})
diff --git a/src/features/inventory/utils/stock.test.ts b/src/features/inventory/utils/stock.test.ts
index e00eb48..e9cf85f 100644
--- a/src/features/inventory/utils/stock.test.ts
+++ b/src/features/inventory/utils/stock.test.ts
@@ -14,7 +14,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 },
...overrides,
}
}
diff --git a/src/features/orders/api.test.ts b/src/features/orders/api.test.ts
index 0d51dc5..d42dd74 100644
--- a/src/features/orders/api.test.ts
+++ b/src/features/orders/api.test.ts
@@ -147,6 +147,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',
@@ -164,6 +165,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',
@@ -219,6 +221,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,
},
}
@@ -385,6 +388,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 }],
},
}
diff --git a/src/features/orders/api.ts b/src/features/orders/api.ts
index 1fa26bc..f682ac6 100644
--- a/src/features/orders/api.ts
+++ b/src/features/orders/api.ts
@@ -48,6 +48,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
@@ -95,6 +96,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
@@ -148,6 +150,7 @@ function toOrder(order: ApiOrderSummary): OrderSummary {
customerAddress: order.customer_address,
customerZipCode: order.customer_zip_code,
status: order.status,
+ requiresShipping: order.requires_shipping,
// 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.
@@ -193,6 +196,7 @@ function toOrderDetail(data: ApiOrderDetail, version: string | null): OrderDetai
customerCity: data.customer_city,
customerProvince: data.customer_province,
status: data.status,
+ requiresShipping: data.requires_shipping,
version,
totalAmount: data.total_amount,
lines: data.order_items.map((item) => ({
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..af34e43 100644
--- a/src/features/orders/components/OrdersTable/OrdersTable.tsx
+++ b/src/features/orders/components/OrdersTable/OrdersTable.tsx
@@ -75,7 +75,7 @@ function buildColumns(onView: (order: OrderSummary) => void): DataTableColumn,
+ render: (order) => ,
},
{
id: 'total',
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 2f3f408..3ea347e 100644
--- a/src/features/orders/content.ts
+++ b/src/features/orders/content.ts
@@ -73,6 +73,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.',
@@ -290,6 +293,15 @@ 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',
fields: {
@@ -354,6 +366,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. */
@@ -478,6 +500,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. */
diff --git a/src/features/orders/hooks/useConfirmDraftOrder.test.tsx b/src/features/orders/hooks/useConfirmDraftOrder.test.tsx
index 2a966f6..141be5e 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 fbe9551..313fc12 100644
--- a/src/features/orders/hooks/useConfirmDraftOrder.ts
+++ b/src/features/orders/hooks/useConfirmDraftOrder.ts
@@ -8,7 +8,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 +55,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) {
const shipment = await createOrderShipment(orderId)
progress.current.shipmentId = shipment.id
diff --git a/src/features/orders/pages/CarrierStepPage.test.tsx b/src/features/orders/pages/CarrierStepPage.test.tsx
index bbc6c1f..5122cba 100644
--- a/src/features/orders/pages/CarrierStepPage.test.tsx
+++ b/src/features/orders/pages/CarrierStepPage.test.tsx
@@ -124,6 +124,63 @@ 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)
+ })
+
+ 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 bd4b501..ce22f67 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 { useEffect, useMemo, useState } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
import { PageWrapper } from 'shared/components'
@@ -68,6 +69,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)
@@ -82,10 +84,14 @@ export function CarrierStepPage() {
)
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],
+ draft === null || !requiresShipping
+ ? null
+ : toDraftQuotePayload(draft.items, draft.origin, draft.destination),
+ [draft, requiresShipping],
)
const quotes = useDraftQuotes(payload)
const confirm = useConfirmDraftOrder({ onOrderCreated: clearDraft })
@@ -108,17 +114,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 || (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,
+ 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)
},
},
@@ -168,16 +187,30 @@ export function CarrierStepPage() {
}}
>
}
- title={carrier.options.groupLabel}
+ icon={
+ requiresShipping ? (
+
+ ) : (
+
+ )
+ }
+ title={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). */}
+ {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()
+ })
+
+ 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 334b181..f902db7 100644
--- a/src/features/orders/pages/OrderDetailPage.tsx
+++ b/src/features/orders/pages/OrderDetailPage.tsx
@@ -203,6 +203,7 @@ export function OrderDetailPage() {
{
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()
})
diff --git a/src/features/orders/pages/ShippingStepPage.tsx b/src/features/orders/pages/ShippingStepPage.tsx
index 261bd6d..8240c11 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))
@@ -142,6 +154,40 @@ 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}
+
+ >
+ }
+ />
+
+
+
= {}): 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/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.tsx b/src/shared/components/ActivityPanel/ActivityPanel.tsx
new file mode 100644
index 0000000..2bf6ae4
--- /dev/null
+++ b/src/shared/components/ActivityPanel/ActivityPanel.tsx
@@ -0,0 +1,117 @@
+import {
+ Alert,
+ Box,
+ Button,
+ Divider,
+ List,
+ ListItemButton,
+ Popover,
+ Typography,
+} from '@mui/material'
+import { Link } from 'react-router-dom'
+
+import { useActivity } from '../../hooks/useActivity'
+import { LoadingSpinner } from '../LoadingSpinner'
+
+import type { ActivityPanelProps } from './ActivityPanel.types'
+import { activityContent } from './content'
+import { activityLabel, activityPath } from './entry'
+
+// 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.
+ */
+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 (
+
+ {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) {
)}
-
-
- {/* 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). Sale del
+ mismo dato que la carga por depósito, así que no agrega ningún
+ request. */}
}
- tone={hasFailures ? 'warning' : 'neutral'}
- tag={hasFailures ? failedEventsCopy.tag : undefined}
- tagTone="warning"
- note={
- failedEvents.value === undefined
- ? undefined
- : failedEventsCopy.note(failedEvents.value)
- }
+ label={storedUnitsCopy.label}
+ value={formatCount(storedUnits)}
+ loading={warehousesLoading}
+ icon={}
+ note={warehousesLoading ? undefined : storedUnitsCopy.note(warehouses.length)}
/>
diff --git a/src/features/dashboard/queryKeys.ts b/src/features/dashboard/queryKeys.ts
index 6de2746..c35d5ff 100644
--- a/src/features/dashboard/queryKeys.ts
+++ b/src/features/dashboard/queryKeys.ts
@@ -23,14 +23,6 @@ export const shipmentKeys = {
recent: (size: number) => [...shipmentKeys.all, 'recent', size] as const,
}
-// Cuelga de `['failed-events']`, la raíz del recurso que cuenta, por el mismo
-// motivo que los otros KPIs: reintentar o descartar un evento refresca el
-// número sin que el panel tenga que enterarse.
-export const failedEventKeys = {
- all: ['failed-events'] as const,
- kpi: (status: string) => ['failed-events', 'kpi', status] as const,
-}
-
// Misma regla que los KPIs de órdenes y envíos: la key cuelga de la raíz del
// recurso que lee —`['inventory']`, la que usa `features/inventory`— para que
// un alta o una edición de producto refresque también el contador del panel.
diff --git a/src/features/inventory/utils/format.test.ts b/src/features/inventory/utils/format.test.ts
index 2276ec3..9f75c01 100644
--- a/src/features/inventory/utils/format.test.ts
+++ b/src/features/inventory/utils/format.test.ts
@@ -40,3 +40,16 @@ describe('formatSpecTimestamp', () => {
expect(formatSpecTimestamp(isoDate)).toBe(`${expectedDate} · ${expectedTime}`)
})
})
+
+// Lo que la API manda es lo que la pantalla imprime, y si una versión vieja no
+// manda un campo que esta pantalla ya lee, `Intl` escribe `NaN`.
+describe('formatUnits with something that is not a number', () => {
+ it('says there is no data instead of printing NaN', () => {
+ expect(formatUnits(undefined as unknown as number)).toBe('—')
+ expect(formatUnits(Number.NaN)).toBe('—')
+ })
+
+ it('still formats a zero, which is a number like any other', () => {
+ expect(formatUnits(0)).toBe('0')
+ })
+})
diff --git a/src/features/inventory/utils/format.ts b/src/features/inventory/utils/format.ts
index 5edae08..3c57b6f 100644
--- a/src/features/inventory/utils/format.ts
+++ b/src/features/inventory/utils/format.ts
@@ -7,6 +7,9 @@
import { formatDate } from 'shared/utils'
+// La marca de «sin dato» del DS, la misma que usa el resto del producto.
+const UNKNOWN_VALUE = '—'
+
const integerFormatter = new Intl.NumberFormat('es-AR', { maximumFractionDigits: 0 })
const decimalFormatter = new Intl.NumberFormat('es-AR', {
@@ -14,9 +17,17 @@ const decimalFormatter = new Intl.NumberFormat('es-AR', {
maximumFractionDigits: 2,
})
-/** Cantidad de unidades: `4280` → `"4.280"`. */
+/**
+ * Cantidad de unidades: `4280` → `"4.280"`.
+ *
+ * Un valor que no es un número finito sale como «—» y no como `NaN`.
+ * TypeScript dice que no puede pasar, pero el dato viene de la API: si una
+ * versión más vieja no manda un campo que esta pantalla ya lee —un front
+ * desplegado antes que su backend—, `Intl` imprime `NaN` en la cara del
+ * operador. «—» dice lo mismo que diría cualquier otro dato que falta.
+ */
export function formatUnits(value: number): string {
- return integerFormatter.format(value)
+ return Number.isFinite(value) ? integerFormatter.format(value) : UNKNOWN_VALUE
}
/** Peso en kg con dos decimales: `1.45` → `"1,45"`. */
diff --git a/src/features/shipments/api.ts b/src/features/shipments/api.ts
new file mode 100644
index 0000000..6583793
--- /dev/null
+++ b/src/features/shipments/api.ts
@@ -0,0 +1,85 @@
+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.
+function toParams({ page, perPage, status, orderId }: ShipmentFilters) {
+ return {
+ page,
+ per_page: perPage,
+ ...(status === undefined ? {} : { status }),
+ ...(orderId === undefined ? {} : { order_id: orderId }),
+ }
+}
+
+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): Promise {
+ return fetchCount('/shipments', status === undefined ? {} : { status })
+}
diff --git a/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx
new file mode 100644
index 0000000..c3673d6
--- /dev/null
+++ b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx
@@ -0,0 +1,165 @@
+import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
+import { Link, Typography } from '@mui/material'
+import { DataTable, StackedCell, StatusBadge } from 'shared/components'
+import type { DataTableAction, DataTableColumn, StatusVariant } from 'shared/components'
+
+import { formatMoney, 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}
+ ) : (
+ {formatMoney(shipment.shippingCost)}
+ ),
+ },
+ {
+ id: 'status',
+ header: columnCopy.status,
+ width: 150,
+ render: (shipment) => (
+
+ ),
+ },
+ {
+ id: 'createdAt',
+ header: columnCopy.createdAt,
+ width: 124,
+ render: (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,
+ onView,
+}: ShipmentsTableProps) {
+ const actions: DataTableAction[] = [
+ {
+ id: 'view',
+ label: table.view,
+ icon: ,
+ onSelect: onView,
+ },
+ ]
+
+ return (
+ shipment.id}
+ label={table.label}
+ tabs={tabs}
+ activeTabId={activeTabId}
+ onTabChange={onTabChange}
+ actions={actions}
+ actionsHeader={columnCopy.status}
+ emptyMessage={table.empty}
+ 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..4a62a82
--- /dev/null
+++ b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.types.ts
@@ -0,0 +1,13 @@
+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
+ /** 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..95b7558
--- /dev/null
+++ b/src/features/shipments/content.ts
@@ -0,0 +1,71 @@
+// Copy centralizado de la feature — sin literales sueltos en el JSX.
+
+const UNITS_FORMAT = new Intl.NumberFormat('es-AR')
+
+const MONEY_FORMAT = new Intl.NumberFormat('es-AR', {
+ style: 'currency',
+ currency: 'ARS',
+ maximumFractionDigits: 0,
+})
+
+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.',
+ },
+ 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',
+ },
+ 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.',
+ /** El detalle del envío vive en la orden: no hay pantalla propia. */
+ view: 'Ver la orden',
+ },
+ pagination: {
+ previous: 'Página anterior',
+ next: 'Página siguiente',
+ page: (number: number) => `Página ${number}`,
+ summary: (from: number, to: number, total: number) =>
+ `Mostrando ${UNITS_FORMAT.format(from)} a ${UNITS_FORMAT.format(to)} de ${UNITS_FORMAT.format(total)} ${total === 1 ? 'envío' : 'envíos'}`,
+ },
+} as const
+
+export function formatCount(value: number): string {
+ return UNITS_FORMAT.format(value)
+}
+
+export function formatMoney(value: number): string {
+ return MONEY_FORMAT.format(value)
+}
diff --git a/src/features/shipments/hooks/useShipments.ts b/src/features/shipments/hooks/useShipments.ts
new file mode 100644
index 0000000..8dcbab9
--- /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() {
+ return useQueries({
+ queries: SHIPMENT_TABS.map(({ status }) => ({
+ queryKey: shipmentKeys.count(status),
+ queryFn: () => fetchShipmentCount(status),
+ })),
+ 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..77cb923
--- /dev/null
+++ b/src/features/shipments/pages/ShipmentsPage.test.tsx
@@ -0,0 +1,154 @@
+import { 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)
+}
+
+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()
+ })
+
+ 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()
+ })
+
+ 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..404bb1f
--- /dev/null
+++ b/src/features/shipments/pages/ShipmentsPage.tsx
@@ -0,0 +1,113 @@
+import { Box, Stack, 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 { ShipmentsTable } from '../components/ShipmentsTable'
+import { formatCount, 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, 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 status = SHIPMENT_TABS.find((tab) => tab.id === tabId)?.status
+ const shipments = useShipmentPage({ page, perPage: PER_PAGE, status })
+ const counts = useShipmentCounts()
+
+ // Cambiar de pestaña vuelve a la primera página: quedarse en la 7 de un
+ // filtro que ahora tiene 2 páginas deja la tabla vacía sin motivo visible.
+ function changeTab(nextTabId: string) {
+ setTabId(nextTabId as ShipmentTabId)
+ 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 : formatCount(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 (
+
+
+
+
+ {pageCopy.title}
+
+
+ {pageCopy.subtitle}
+
+
+
+
+
+
+ )
+}
diff --git a/src/features/shipments/queryKeys.ts b/src/features/shipments/queryKeys.ts
new file mode 100644
index 0000000..b4dd8af
--- /dev/null
+++ b/src/features/shipments/queryKeys.ts
@@ -0,0 +1,17 @@
+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.
+ count: (status: ShipmentStatus | undefined) =>
+ [...shipmentKeys.counts(), status ?? 'all'] as const,
+}
diff --git a/src/features/shipments/types.ts b/src/features/shipments/types.ts
new file mode 100644
index 0000000..c04cad9
--- /dev/null
+++ b/src/features/shipments/types.ts
@@ -0,0 +1,46 @@
+// 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
+}
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/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.tsx b/src/shared/components/ActivityPanel/ActivityPanel.tsx
index 2bf6ae4..f532b12 100644
--- a/src/shared/components/ActivityPanel/ActivityPanel.tsx
+++ b/src/shared/components/ActivityPanel/ActivityPanel.tsx
@@ -1,3 +1,6 @@
+import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
+import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
+import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined'
import {
Alert,
Box,
@@ -8,15 +11,27 @@ import {
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.
@@ -50,7 +65,7 @@ export function ActivityPanel({ anchorEl, onClose, paths }: ActivityPanelProps)
onClose={onClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
- slotProps={{ paper: { sx: { width: 360, maxWidth: '100vw', mt: 1 } } }}
+ slotProps={{ paper: { sx: { width: 420, maxWidth: '100vw', mt: 1 } } }}
>
{activityContent.title}
@@ -102,12 +117,25 @@ export function ActivityPanel({ anchorEl, onClose, paths }: ActivityPanelProps)
onClick={onClose}
// Sin destino no es un botón: es una fila que informa y nada más.
disableRipple={to === null}
- sx={{ display: 'block', cursor: to === null ? 'default' : 'pointer' }}
+ divider
+ sx={{
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: 1.5,
+ // Fila alta y con aire: es un historial que se lee, no un menú
+ // que se recorre con la vista.
+ py: 1.75,
+ px: 2,
+ cursor: to === null ? 'default' : 'pointer',
+ }}
>
- {activityLabel(entry)}
-
- {WHEN.format(new Date(entry.occurredAt))}
-
+ {ICONS[entry.type]}
+
+ {activityLabel(entry)}
+
+ {WHEN.format(new Date(entry.occurredAt))}
+
+
)
})}
From f286040d136ac744ab7eb6d868da722e07cdbb12 Mon Sep 17 00:00:00 2001
From: Tomas Martin
Date: Sun, 4 Oct 2026 20:46:51 -0300
Subject: [PATCH 03/10] feat: [TESIS-163] put the eye straight in the shipments
row
The listing had a single action behind a kebab, and its column reused the
"Estado" header, so the table showed that word twice: once over the badge
and once over a menu that only held "Ver la orden". Opening a menu to pick
the only thing in it is a click that buys nothing.
The action is now its own column with the eye in every row, under an
"Acciones" header. Each button names the shipment it belongs to, because the
icon repeats down the table and "Ver la orden" alone would not say which one.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp
---
.../ShipmentsTable/ShipmentsTable.tsx | 34 ++++++++++++-------
src/features/shipments/content.ts | 3 ++
.../shipments/pages/ShipmentsPage.test.tsx | 11 ++++++
3 files changed, 35 insertions(+), 13 deletions(-)
diff --git a/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx
index c3673d6..813cb8c 100644
--- a/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx
+++ b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx
@@ -1,7 +1,7 @@
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
-import { Link, Typography } from '@mui/material'
+import { IconButton, Link, Tooltip, Typography } from '@mui/material'
import { DataTable, StackedCell, StatusBadge } from 'shared/components'
-import type { DataTableAction, DataTableColumn, StatusVariant } from 'shared/components'
+import type { DataTableColumn, StatusVariant } from 'shared/components'
import { formatMoney, shipmentsCopy } from '../../content'
import type { ShipmentStatus, ShipmentSummary } from '../../types'
@@ -116,6 +116,25 @@ function buildColumns(
/>
),
},
+ {
+ // 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)}
+ >
+
+
+
+ ),
+ },
]
}
@@ -133,15 +152,6 @@ export function ShipmentsTable({
pagination,
onView,
}: ShipmentsTableProps) {
- const actions: DataTableAction[] = [
- {
- id: 'view',
- label: table.view,
- icon: ,
- onSelect: onView,
- },
- ]
-
return (
`Ver la orden del envío #${id}`,
},
pagination: {
previous: 'Página anterior',
diff --git a/src/features/shipments/pages/ShipmentsPage.test.tsx b/src/features/shipments/pages/ShipmentsPage.test.tsx
index 77cb923..12e32c0 100644
--- a/src/features/shipments/pages/ShipmentsPage.test.tsx
+++ b/src/features/shipments/pages/ShipmentsPage.test.tsx
@@ -104,6 +104,17 @@ describe('ShipmentsPage', () => {
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()
From ec3a40ed410406b2cd76a7c13f7a5ead788b4323 Mon Sep 17 00:00:00 2001
From: Tomas Martin
Date: Mon, 5 Oct 2026 08:39:44 -0300
Subject: [PATCH 04/10] fix: [TESIS-163] act on the review: stale caches, a
lost pickup flag and absent fields
Five of the findings reported on this PR.
The shipments listing, its tab counters and the bell were never invalidated.
Creating an order opens a shipment and writes an activity entry, dispatching
one changes its state, and editing an order can drop it, but the three
mutations only refreshed orders and inventory, so three screens kept showing
the previous state for the five minutes of staleTime. `['shipments']` goes in
as a literal because it belongs to another feature; the activity feed lives in
`shared`, so that one uses its own key factory.
`requiresShipping` was in the draft store but not in its `partialize`, so
reloading on step 2 or 3 dropped it back to its `true` default and silently
turned a pickup into a shipment.
Three places trusted a field to be there:
- A warehouse with no `capacity` passed the `!== null` check and made the bar
`NaN %`. A capacity of zero did the same through `0 / 0`. Absent now reads as
"not declared", and the guard is `> 0`.
- The stored-units KPI summed an empty list when `/warehouses` failed and
announced zero units. Zero is a real answer -- the company stores nothing --
and this is not it, so the figure is now absent and the card says the load
could not be loaded instead of claiming there are no warehouses.
- An order whose API does not send `requires_shipping` was read as a pickup,
because `undefined` is falsy. Both mappings default to shipping, which is
what every order was before pickups existed.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp
---
src/features/dashboard/api.ts | 7 ++-
.../WarehouseLoadCard/WarehouseLoadCard.tsx | 6 +-
.../WarehouseLoadCard.types.ts | 3 +-
src/features/dashboard/content.ts | 2 +
.../hooks/useInventoryAlerts.test.tsx | 62 +++++++++++++++++++
.../dashboard/hooks/useInventoryAlerts.ts | 10 ++-
.../dashboard/pages/DashboardPage.test.tsx | 29 +++++++++
.../dashboard/pages/DashboardPage.tsx | 6 +-
src/features/dashboard/utils/capacity.test.ts | 22 +++++++
src/features/dashboard/utils/capacity.ts | 6 +-
src/features/orders/api.test.ts | 31 ++++++++++
src/features/orders/api.ts | 7 ++-
.../orders/hooks/useConfirmDraftOrder.ts | 8 ++-
.../orders/hooks/useDispatchShipment.test.tsx | 23 +++++++
.../orders/hooks/useDispatchShipment.ts | 12 +++-
src/features/orders/hooks/useUpdateOrder.ts | 4 ++
src/shared/store/orderDraftStore.test.ts | 10 +++
src/shared/store/orderDraftStore.ts | 4 ++
18 files changed, 238 insertions(+), 14 deletions(-)
create mode 100644 src/features/dashboard/hooks/useInventoryAlerts.test.tsx
diff --git a/src/features/dashboard/api.ts b/src/features/dashboard/api.ts
index 52d1ecf..18b6edf 100644
--- a/src/features/dashboard/api.ts
+++ b/src/features/dashboard/api.ts
@@ -93,8 +93,11 @@ export async function fetchWarehouseLoads(): Promise {
return data.data.map((warehouse) => ({
id: warehouse.id,
name: warehouse.name,
- storedUnits: warehouse.stored_units,
- capacity: warehouse.capacity,
+ // `?? 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,
}))
}
diff --git a/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx b/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx
index 5c3173e..7f02c8b 100644
--- a/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx
+++ b/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx
@@ -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)}
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 6e11626..34902ef 100644
--- a/src/features/dashboard/content.ts
+++ b/src/features/dashboard/content.ts
@@ -76,6 +76,8 @@ export const dashboardCopy = {
barLabelWithCapacity: (name: string, units: number, capacity: number) =>
`${name}: ${UNITS_FORMAT.format(units)} de ${UNITS_FORMAT.format(capacity)} unidades de capacidad`,
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.
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/pages/DashboardPage.test.tsx b/src/features/dashboard/pages/DashboardPage.test.tsx
index 91c978d..14b6a50 100644
--- a/src/features/dashboard/pages/DashboardPage.test.tsx
+++ b/src/features/dashboard/pages/DashboardPage.test.tsx
@@ -111,6 +111,35 @@ beforeEach(() => {
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.
diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx
index eff3662..e24f75e 100644
--- a/src/features/dashboard/pages/DashboardPage.tsx
+++ b/src/features/dashboard/pages/DashboardPage.tsx
@@ -166,7 +166,11 @@ export function DashboardPage() {
value={formatCount(storedUnits)}
loading={warehousesLoading}
icon={}
- note={warehousesLoading ? undefined : storedUnitsCopy.note(warehouses.length)}
+ note={
+ warehousesLoading || storedUnits === undefined
+ ? undefined
+ : storedUnitsCopy.note(warehouses.length)
+ }
/>
diff --git a/src/features/dashboard/utils/capacity.test.ts b/src/features/dashboard/utils/capacity.test.ts
index 78fe724..72e3126 100644
--- a/src/features/dashboard/utils/capacity.test.ts
+++ b/src/features/dashboard/utils/capacity.test.ts
@@ -102,3 +102,25 @@ describe('warehouseShares with a declared capacity', () => {
})
})
})
+
+// 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 d02824e..155ddc3 100644
--- a/src/features/dashboard/utils/capacity.ts
+++ b/src/features/dashboard/utils/capacity.ts
@@ -38,12 +38,14 @@ export function warehouseShares(warehouses: WarehouseLoad[]): WarehouseShare[] {
.map((warehouse) => ({
...warehouse,
share: shareOf(warehouse, busiest),
- measuredAgainstCapacity: warehouse.capacity !== null,
+ measuredAgainstCapacity: warehouse.capacity !== null && warehouse.capacity > 0,
}))
}
function shareOf(warehouse: WarehouseLoad, busiest: number): number {
- if (warehouse.capacity !== null) {
+ // `> 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)
}
diff --git a/src/features/orders/api.test.ts b/src/features/orders/api.test.ts
index d42dd74..8a21d5d 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,
@@ -197,6 +198,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"' }),
@@ -420,3 +434,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 f682ac6..fa53305 100644
--- a/src/features/orders/api.ts
+++ b/src/features/orders/api.ts
@@ -150,7 +150,10 @@ function toOrder(order: ApiOrderSummary): OrderSummary {
customerAddress: order.customer_address,
customerZipCode: order.customer_zip_code,
status: order.status,
- requiresShipping: order.requires_shipping,
+ // `?? 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.
@@ -196,7 +199,7 @@ function toOrderDetail(data: ApiOrderDetail, version: string | null): OrderDetai
customerCity: data.customer_city,
customerProvince: data.customer_province,
status: data.status,
- requiresShipping: data.requires_shipping,
+ requiresShipping: data.requires_shipping ?? true,
version,
totalAmount: data.total_amount,
lines: data.order_items.map((item) => ({
diff --git a/src/features/orders/hooks/useConfirmDraftOrder.ts b/src/features/orders/hooks/useConfirmDraftOrder.ts
index 313fc12..976bef6 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'
@@ -75,8 +76,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/shared/store/orderDraftStore.test.ts b/src/shared/store/orderDraftStore.test.ts
index 8eae9a5..cc48649 100644
--- a/src/shared/store/orderDraftStore.test.ts
+++ b/src/shared/store/orderDraftStore.test.ts
@@ -112,6 +112,16 @@ describe('origin and destination', () => {
expect(stored.state.destination).toEqual(DESTINATION)
})
+
+ // Sin persistirlo, recargar lo devuelve a su default `true` y el retiro se
+ // convierte en un envío sin que nadie lo haya pedido.
+ it('remembers that the sale is picked up at the store across a reload', () => {
+ useOrderDraftStore.getState().setRequiresShipping(false)
+
+ const stored = JSON.parse(sessionStorage.getItem('order-draft-store') ?? '{}')
+
+ expect(stored.state.requiresShipping).toBe(false)
+ })
})
describe('clearDraft', () => {
diff --git a/src/shared/store/orderDraftStore.ts b/src/shared/store/orderDraftStore.ts
index 97d1eca..148412e 100644
--- a/src/shared/store/orderDraftStore.ts
+++ b/src/shared/store/orderDraftStore.ts
@@ -126,11 +126,15 @@ export const useOrderDraftStore = create()(
{
name: 'order-draft-store',
storage: createJSONStorage(() => sessionStorage),
+ // `requiresShipping` también se persiste: sin él, recargar en el paso 2 o
+ // 3 lo devuelve a su default `true` y convierte un retiro en un envío sin
+ // decir nada.
partialize: (state) => ({
customer: state.customer,
items: state.items,
origin: state.origin,
destination: state.destination,
+ requiresShipping: state.requiresShipping,
}),
},
),
From e30009243c8b91b960e3f09c1d84f04590194863 Mon Sep 17 00:00:00 2001
From: Tomas Martin
Date: Mon, 5 Oct 2026 08:50:07 -0300
Subject: [PATCH 05/10] fix: [TESIS-163] freeze the pickup flag and headline
the stock the buckets add up to
Two more findings from the review.
Step 3 kept reading `requiresShipping` from the store while everything else
came from the copy frozen when the button was pressed. `onOrderCreated` empties
the draft as soon as the order exists, and that puts the flag back to its
`true` default, so mid-confirmation a pickup started asking the carriers for
prices, the panel flickered into the carrier list and the confirm button went
disabled for want of a chosen option. The flag now travels inside the snapshot,
and the whole render reads it from there.
The master stock card headlined `totalStock` -- the units nobody sold yet --
while one of its buckets counted the committed ones. The two never added up:
committed + available-to-promise is `onHand`, which the API already sends and
nothing read. The headline is now `onHand` and the card closes, with in-transit
still outside it because those units are in no warehouse yet. Its caption
counts distribution positions instead of `stocks`, so a warehouse whose last
unit was sold still counts.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp
---
.../pages/ProductDetailPage.test.tsx | 18 ++++++++++-
.../inventory/pages/ProductDetailPage.tsx | 12 +++++--
.../orders/pages/CarrierStepPage.test.tsx | 27 +++++++++++++++-
src/features/orders/pages/CarrierStepPage.tsx | 31 ++++++++++++-------
4 files changed, 72 insertions(+), 16 deletions(-)
diff --git a/src/features/inventory/pages/ProductDetailPage.test.tsx b/src/features/inventory/pages/ProductDetailPage.test.tsx
index 0998183..fdb598d 100644
--- a/src/features/inventory/pages/ProductDetailPage.test.tsx
+++ b/src/features/inventory/pages/ProductDetailPage.test.tsx
@@ -71,6 +71,11 @@ const SEEDED_MOUSE: Product = {
},
],
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: [
@@ -205,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()
diff --git a/src/features/inventory/pages/ProductDetailPage.tsx b/src/features/inventory/pages/ProductDetailPage.tsx
index 0b7d45d..959bd48 100644
--- a/src/features/inventory/pages/ProductDetailPage.tsx
+++ b/src/features/inventory/pages/ProductDetailPage.tsx
@@ -293,9 +293,17 @@ export function ProductDetailPage() {
/>
+ {/* 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('stock')}
/>
diff --git a/src/features/orders/pages/CarrierStepPage.test.tsx b/src/features/orders/pages/CarrierStepPage.test.tsx
index 5122cba..425abe2 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'
@@ -166,6 +166,31 @@ describe('CarrierStepPage · pickup at the store', () => {
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()
diff --git a/src/features/orders/pages/CarrierStepPage.tsx b/src/features/orders/pages/CarrierStepPage.tsx
index ce22f67..4e398b6 100644
--- a/src/features/orders/pages/CarrierStepPage.tsx
+++ b/src/features/orders/pages/CarrierStepPage.tsx
@@ -41,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
}
/**
@@ -78,9 +85,9 @@ 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
@@ -88,10 +95,10 @@ export function CarrierStepPage() {
// tampoco hay a quién preguntarle el precio (TESIS-162).
const payload = useMemo(
() =>
- draft === null || !requiresShipping
- ? null
- : toDraftQuotePayload(draft.items, draft.origin, draft.destination),
- [draft, requiresShipping],
+ draft?.requiresShipping
+ ? toDraftQuotePayload(draft.items, draft.origin, draft.destination)
+ : null,
+ [draft],
)
const quotes = useDraftQuotes(payload)
const confirm = useConfirmDraftOrder({ onOrderCreated: clearDraft })
@@ -115,7 +122,7 @@ export function CarrierStepPage() {
function confirmOrder() {
// Con envío hace falta la opción elegida; con retiro, no hay ninguna.
- if (draft === null || (requiresShipping && chosen === null)) return
+ if (draft === null || (draft.requiresShipping && chosen === null)) return
setConfirmed(draft)
confirm.mutate(
@@ -125,7 +132,7 @@ export function CarrierStepPage() {
draft.items,
draft.origin,
draft.destination,
- requiresShipping,
+ draft.requiresShipping,
),
dispatch: chosen === null ? null : toDispatchPayload(chosen, draft.origin.warehouseId),
},
@@ -188,17 +195,17 @@ export function CarrierStepPage() {
>
) : (
)
}
- title={requiresShipping ? carrier.options.groupLabel : carrier.pickup.title}
+ title={draft.requiresShipping ? carrier.options.groupLabel : carrier.pickup.title}
>
{/* Con retiro en el local no hay nada que cotizar: la venta se
registra y el cliente la busca (TESIS-162). */}
- {requiresShipping ? (
+ {draft.requiresShipping ? (
Date: Mon, 5 Oct 2026 10:16:25 -0300
Subject: [PATCH 06/10] fix: [TESIS-163] close the minor findings: the pickup
form, the bars, the modal and the rows
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The rest of the review, minus two that became their own cards.
Step 2 demanded a delivery address even for a pickup. A comment right above
the section said it stopped being asked for, and it did not: the form gated
the button on the destination schema whatever the choice. Now it only gates
when the order ships, the section says the address is optional, and whatever
was typed still travels -- the customer's address can matter for the invoice,
and step 3 needs a destination to not send you back.
The load bars without a declared capacity said how many units a warehouse
holds but not what the bar compares against, while the ones with a capacity
read as an occupancy. The two look identical and mean different things, so the
card now says it once below the list. Their accessible label was also keyed on
`capacity === null`, which disagreed with the bar itself for a capacity of
zero: it would have announced "of 0 units of capacity" while the bar compared
against the fullest warehouse.
The stock editor flashed the full product form on its way out: the scope was
read as `editing ?? 'product'`, and closing set `editing` to null while the
modal was still animating. Open state and scope are two pieces of state now,
and the scope only changes when something opens.
Rows in the bell panel that lead nowhere were still announced as buttons --
`ListItemButton` does that even mounted on a `div` -- and closed the panel when
touched. A row that only informs no longer promises an action. In "Últimos
envíos" the icon of every row carried the card's own title as its label, which
is noise to a screen reader, and the row's name now says what activating it
does.
Three footnotes explained that the API did not expose the committed units, the
per-warehouse in-transit, the packaging or the technical standard. It exposes
all four since TESIS-162 and TESIS-144, so the text, the copy and the three
props nothing passes any more are gone.
New tests for the feed, for `fetchRecentShipments` and for the warehouse load
mapping, which the review asked for.
Two findings left this PR as cards of their own, because both reach well past
it: the five requests behind the shipment tab counters (TESIS-165, it needs an
endpoint) and the ten copies of three number formatters spread over seven
features (TESIS-166).
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp
---
src/features/dashboard/api.test.ts | 93 ++++++++++++++
.../RecentShipmentsCard.tsx | 4 +-
.../WarehouseLoadCard/WarehouseLoadCard.tsx | 16 ++-
src/features/dashboard/content.ts | 6 +
.../dashboard/pages/DashboardPage.test.tsx | 28 ++++
.../MasterStockCard/MasterStockCard.tsx | 7 -
.../MasterStockCard/MasterStockCard.types.ts | 2 -
.../ProductSpecsCard/ProductSpecsCard.tsx | 8 +-
.../ProductSpecsCard.types.ts | 2 -
.../WarehouseDistributionCard.tsx | 7 +-
.../WarehouseDistributionCard.types.ts | 2 -
.../pages/ProductDetailPage.test.tsx | 15 +++
.../inventory/pages/ProductDetailPage.tsx | 44 ++++---
.../DestinationFieldsCard.tsx | 6 +-
.../DestinationFieldsCard.types.ts | 6 +
src/features/orders/content.ts | 2 +
.../orders/pages/ShippingStepPage.test.tsx | 46 +++++++
.../orders/pages/ShippingStepPage.tsx | 22 +++-
.../ActivityPanel/ActivityPanel.test.tsx | 120 ++++++++++++++++++
.../ActivityPanel/ActivityPanel.tsx | 51 ++++----
20 files changed, 406 insertions(+), 81 deletions(-)
create mode 100644 src/features/dashboard/api.test.ts
create mode 100644 src/shared/components/ActivityPanel/ActivityPanel.test.tsx
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/components/RecentShipmentsCard/RecentShipmentsCard.tsx b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.tsx
index 32d71c0..d36d844 100644
--- a/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.tsx
+++ b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.tsx
@@ -55,10 +55,10 @@ export function RecentShipmentsCard({ shipments, loading = false }: RecentShipme
-
+
diff --git a/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx b/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx
index 7f02c8b..3a3e295 100644
--- a/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx
+++ b/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx
@@ -77,19 +77,29 @@ export function WarehouseLoadCard({
value={warehouse.share}
size="medium"
track="neutral"
+ // `measuredAgainstCapacity` y no `capacity === null`: un
+ // techo en cero tampoco define una ocupación, y con el otro
+ // criterio el rótulo diría «de 0 unidades de capacidad»
+ // mientras la barra compara contra el más cargado.
ariaLabel={
- warehouse.capacity === null
- ? copy.barLabel(warehouse.name, warehouse.storedUnits)
- : copy.barLabelWithCapacity(
+ warehouse.measuredAgainstCapacity && warehouse.capacity !== null
+ ? copy.barLabelWithCapacity(
warehouse.name,
warehouse.storedUnits,
warehouse.capacity,
)
+ : copy.barLabel(warehouse.name, warehouse.storedUnits)
}
/>
))}
+
+ {warehouses.some((warehouse) => !warehouse.measuredAgainstCapacity) && (
+
+ {copy.relativeNote}
+
+ )}
>
)}
diff --git a/src/features/dashboard/content.ts b/src/features/dashboard/content.ts
index 34902ef..a63626a 100644
--- a/src/features/dashboard/content.ts
+++ b/src/features/dashboard/content.ts
@@ -75,6 +75,10 @@ export const dashboardCopy = {
`${name}: ${UNITS_FORMAT.format(units)} ${units === 1 ? 'unidad' : 'unidades'}`,
barLabelWithCapacity: (name: string, units: number, capacity: number) =>
`${name}: ${UNITS_FORMAT.format(units)} de ${UNITS_FORMAT.format(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.',
@@ -115,6 +119,8 @@ export const dashboardCopy = {
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}`,
diff --git a/src/features/dashboard/pages/DashboardPage.test.tsx b/src/features/dashboard/pages/DashboardPage.test.tsx
index 14b6a50..b1e1dd2 100644
--- a/src/features/dashboard/pages/DashboardPage.test.tsx
+++ b/src/features/dashboard/pages/DashboardPage.test.tsx
@@ -272,6 +272,34 @@ describe('DashboardPage · warehouse load', () => {
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: [
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/pages/ProductDetailPage.test.tsx b/src/features/inventory/pages/ProductDetailPage.test.tsx
index fdb598d..8808df1 100644
--- a/src/features/inventory/pages/ProductDetailPage.test.tsx
+++ b/src/features/inventory/pages/ProductDetailPage.test.tsx
@@ -256,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 959bd48..f648d43 100644
--- a/src/features/inventory/pages/ProductDetailPage.tsx
+++ b/src/features/inventory/pages/ProductDetailPage.tsx
@@ -33,7 +33,7 @@ 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
@@ -175,9 +175,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()
@@ -191,9 +193,20 @@ export function ProductDetailPage() {
const { pathname, state } = useLocation()
const navigate = useNavigate()
const abrirEdicion = typeof state === 'object' && state !== null && 'edit' in state
- // Qué alcance abre el modal, o `null` si está cerrado: «Editar producto»
- // abre el formulario entero y «Editar stock» sólo las cantidades.
- const [editing, setEditing] = useState(abrirEdicion ? 'product' : null)
+ // 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
@@ -241,7 +254,7 @@ export function ProductDetailPage() {
updateMutation.mutate(payload, {
onSuccess: () => {
notify(page.saved(name), 'success')
- setEditing(null)
+ setEditing(false)
setBaseline(undefined)
},
onError: () => void product.refetch(),
@@ -284,7 +297,7 @@ export function ProductDetailPage() {
statusLabel={stockLabel(product.data.stockStatus)}
statusVariant={stockVariant(product.data.stockStatus)}
catalogPath={CATALOG_PATH}
- onEdit={() => setEditing('product')}
+ onEdit={() => openEditor('product')}
/>
setEditing('stock')}
- />
- openEditor('stock')}
/>
+
{/* Canales de venta: sólo para las empresas con la feature encendida,
@@ -332,8 +342,8 @@ export function ProductDetailPage() {
} title={copy.title}>
+ }
+ title={optional ? copy.optionalTitle : copy.title}
+ >
{
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 8240c11..710b870 100644
--- a/src/features/orders/pages/ShippingStepPage.tsx
+++ b/src/features/orders/pages/ShippingStepPage.tsx
@@ -128,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())
@@ -188,6 +197,8 @@ export function ShippingStepPage() {
+ {/* 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
@@ -207,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/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
index f532b12..eb95210 100644
--- a/src/shared/components/ActivityPanel/ActivityPanel.tsx
+++ b/src/shared/components/ActivityPanel/ActivityPanel.tsx
@@ -1,16 +1,7 @@
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,
- ListItemButton,
- Popover,
- Typography,
-} from '@mui/material'
+import { Alert, Box, Button, Divider, List, ListItem, Popover, Typography } from '@mui/material'
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
@@ -52,6 +43,18 @@ const WHEN = new Intl.DateTimeFormat('es-AR', {
* 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
@@ -110,24 +113,18 @@ export function ActivityPanel({ anchorEl, onClose, paths }: ActivityPanelProps)
const to = activityPath(entry, paths)
return (
- {ICONS[entry.type]}
@@ -136,7 +133,7 @@ export function ActivityPanel({ anchorEl, onClose, paths }: ActivityPanelProps)
{WHEN.format(new Date(entry.occurredAt))}
-
+
)
})}
From 26f144a8966900a997fe87204a6f91ddc0bec84d Mon Sep 17 00:00:00 2001
From: Tomas Martin <128748466+TomasMartin2004@users.noreply.github.com>
Date: Mon, 5 Oct 2026 20:36:58 -0300
Subject: [PATCH 07/10] feat: [TESIS-164] search the shipments listing by
tracking and order (#80)
The listing shipped with tabs and pagination but no way in. When a buyer
calls, the operator holds the tracking number the courier issued or the id
the sales channel gave the order, and neither could be typed anywhere.
A search box goes in the header, debounced so a sixteen-character tracking
number is one request and not sixteen. The term travels to the API as the
`search` param TESIS-164 added on the backend: the filtering is the database's
job, because filtering here would only see the page already fetched.
Three things the term has to respect:
- It goes into the tab counters too. Without that the tabs would keep
reporting how many shipments the company has while the table shows three.
- It resets the page. Searching from page 3 returned an empty page, since the
result of a search fits in one.
- It narrows the active tab instead of replacing it.
The empty table now says which emptiness it is: a tab with no shipments and a
search with no matches read the same to the code and not to the person, so the
page picks the message and the table takes it as a prop.
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp
Co-authored-by: Claude Opus 5 (1M context)
---
src/features/shipments/api.test.ts | 69 +++++++++++++++
src/features/shipments/api.ts | 14 +++-
.../ShipmentsTable/ShipmentsTable.tsx | 3 +-
.../ShipmentsTable/ShipmentsTable.types.ts | 6 ++
src/features/shipments/content.ts | 5 ++
src/features/shipments/hooks/useShipments.ts | 6 +-
.../shipments/pages/ShipmentsPage.test.tsx | 83 ++++++++++++++++++-
.../shipments/pages/ShipmentsPage.tsx | 76 +++++++++++++----
src/features/shipments/queryKeys.ts | 7 +-
src/features/shipments/types.ts | 2 +
10 files changed, 246 insertions(+), 25 deletions(-)
create mode 100644 src/features/shipments/api.test.ts
diff --git a/src/features/shipments/api.test.ts b/src/features/shipments/api.test.ts
new file mode 100644
index 0000000..c170009
--- /dev/null
+++ b/src/features/shipments/api.test.ts
@@ -0,0 +1,69 @@
+import type { AxiosResponse } from 'axios'
+import { client } from 'shared/api/client'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { fetchShipmentCount, fetchShipmentPage } from './api'
+
+// Sólo el `data` importa: la frontera no lee headers ni status de estas respuestas.
+function respond(data: unknown): AxiosResponse {
+ return { data, headers: {} } as AxiosResponse
+}
+
+/** Los query params con los que se llamó al endpoint. */
+function capture() {
+ const sent: Record[] = []
+ vi.spyOn(client, 'get').mockImplementation((_url: string, config?: unknown) => {
+ sent.push((config as { params: Record }).params)
+
+ return Promise.resolve(respond({ data: [], meta: { page: 1, per_page: 20, total: 0 } }))
+ })
+
+ return sent
+}
+
+beforeEach(() => {
+ vi.restoreAllMocks()
+})
+
+// El filtro lo hace el backend (TESIS-164): lo que esta capa tiene que
+// garantizar es que el término llegue, y que el vacío no.
+describe('fetchShipmentPage', () => {
+ it('sends the term as the search parameter', async () => {
+ const sent = capture()
+ await fetchShipmentPage({ page: 1, perPage: 20, search: 'AND-9920' })
+
+ expect(sent[0].search).toBe('AND-9920')
+ })
+
+ // Un `search=` en blanco ensucia la URL y la clave de caché sin cambiar la
+ // respuesta: el backend corta antes de armar la condición.
+ it('omits search when there is no term', async () => {
+ const sent = capture()
+ await fetchShipmentPage({ page: 1, perPage: 20, search: '' })
+
+ expect(sent[0]).not.toHaveProperty('search')
+ })
+
+ it('keeps the status filter alongside the term', async () => {
+ const sent = capture()
+ await fetchShipmentPage({ page: 1, perPage: 20, status: 'pending', search: 'AND' })
+
+ expect(sent[0]).toMatchObject({ status: 'pending', search: 'AND' })
+ })
+})
+
+describe('fetchShipmentCount', () => {
+ it('counts with the term applied', async () => {
+ const sent = capture()
+ await fetchShipmentCount('pending', 'AND')
+
+ expect(sent[0]).toMatchObject({ status: 'pending', search: 'AND', per_page: 1 })
+ })
+
+ it('omits search when there is no term', async () => {
+ const sent = capture()
+ await fetchShipmentCount('pending')
+
+ expect(sent[0]).not.toHaveProperty('search')
+ })
+})
diff --git a/src/features/shipments/api.ts b/src/features/shipments/api.ts
index 6583793..79f28de 100644
--- a/src/features/shipments/api.ts
+++ b/src/features/shipments/api.ts
@@ -50,13 +50,16 @@ function toShipment(shipment: ApiShipmentSummary): ShipmentSummary {
}
// Un filtro vacío no viaja: mandar `status=` en blanco haría que el backend
-// filtre por el string vacío y devuelva cero filas.
-function toParams({ page, perPage, status, orderId }: ShipmentFilters) {
+// filtre por el string vacío y devuelva cero filas. Con `search` el backend sí
+// tolera el vacío —corta antes de armar la condición—, pero mandarlo igual
+// ensucia la URL y la clave de caché, así que se omite por el mismo criterio.
+function toParams({ page, perPage, status, orderId, search }: ShipmentFilters) {
return {
page,
per_page: perPage,
...(status === undefined ? {} : { status }),
...(orderId === undefined ? {} : { order_id: orderId }),
+ ...(search ? { search } : {}),
}
}
@@ -80,6 +83,9 @@ export async function fetchShipmentPage(filters: ShipmentFilters): Promise {
- return fetchCount('/shipments', status === undefined ? {} : { status })
+export function fetchShipmentCount(status?: ShipmentStatus, search = ''): Promise {
+ return fetchCount('/shipments', {
+ ...(status === undefined ? {} : { status }),
+ ...(search ? { search } : {}),
+ })
}
diff --git a/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx
index 813cb8c..97e40e4 100644
--- a/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx
+++ b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx
@@ -150,6 +150,7 @@ export function ShipmentsTable({
activeTabId,
onTabChange,
pagination,
+ emptyMessage,
onView,
}: ShipmentsTableProps) {
return (
@@ -161,7 +162,7 @@ export function ShipmentsTable({
tabs={tabs}
activeTabId={activeTabId}
onTabChange={onTabChange}
- emptyMessage={table.empty}
+ emptyMessage={emptyMessage}
pagination={pagination}
paginationLabels={{
previousLabel: shipmentsCopy.pagination.previous,
diff --git a/src/features/shipments/components/ShipmentsTable/ShipmentsTable.types.ts b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.types.ts
index 4a62a82..b778005 100644
--- a/src/features/shipments/components/ShipmentsTable/ShipmentsTable.types.ts
+++ b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.types.ts
@@ -8,6 +8,12 @@ export interface ShipmentsTableProps {
activeTabId: string
onTabChange: (tabId: string) => void
pagination: DataTablePagination
+ /**
+ * Qué decir con la tabla vacía. Lo elige la pantalla porque depende de si se
+ * buscó algo: una pestaña sin envíos y una búsqueda sin resultados no son lo
+ * mismo para quien está mirando.
+ */
+ emptyMessage: string
/** Abre la orden del envío: su detalle vive ahí, no en una pantalla propia. */
onView: (shipment: ShipmentSummary) => void
}
diff --git a/src/features/shipments/content.ts b/src/features/shipments/content.ts
index bcac3e5..e09b0c9 100644
--- a/src/features/shipments/content.ts
+++ b/src/features/shipments/content.ts
@@ -13,6 +13,9 @@ export const shipmentsCopy = {
title: 'Envíos',
subtitle: 'Todos los envíos de la empresa y en qué anda cada uno.',
error: 'No pudimos cargar los envíos.',
+ searchLabel: 'Buscar',
+ /** Los dos códigos que el operador tiene en la mano cuando lo llaman. */
+ searchPlaceholder: 'Seguimiento u orden del canal',
},
tabs: {
all: 'Todos',
@@ -51,6 +54,8 @@ export const shipmentsCopy = {
table: {
label: 'Envíos de la empresa',
empty: 'No hay envíos para este filtro.',
+ /** Buscar y no encontrar no es lo mismo que una pestaña vacía. */
+ emptySearch: 'Ningún envío coincide con la búsqueda.',
/** El detalle del envío vive en la orden: no hay pantalla propia. */
view: 'Ver la orden',
/** El ojito se repite en cada fila: el nombre accesible dice cuál es. */
diff --git a/src/features/shipments/hooks/useShipments.ts b/src/features/shipments/hooks/useShipments.ts
index 8dcbab9..846eced 100644
--- a/src/features/shipments/hooks/useShipments.ts
+++ b/src/features/shipments/hooks/useShipments.ts
@@ -46,11 +46,11 @@ export function useShipmentPage(filters: ShipmentFilters) {
* Que uno falle no voltea la pantalla: la tabla se ve igual y esa pestaña queda
* sin número.
*/
-export function useShipmentCounts() {
+export function useShipmentCounts(search = '') {
return useQueries({
queries: SHIPMENT_TABS.map(({ status }) => ({
- queryKey: shipmentKeys.count(status),
- queryFn: () => fetchShipmentCount(status),
+ queryKey: shipmentKeys.count(status, search),
+ queryFn: () => fetchShipmentCount(status, search),
})),
combine: (results) => results.map((result) => result.data),
})
diff --git a/src/features/shipments/pages/ShipmentsPage.test.tsx b/src/features/shipments/pages/ShipmentsPage.test.tsx
index 12e32c0..3a50eaf 100644
--- a/src/features/shipments/pages/ShipmentsPage.test.tsx
+++ b/src/features/shipments/pages/ShipmentsPage.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, screen, within } from '@testing-library/react'
+import { act, fireEvent, screen, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -47,6 +47,21 @@ function mockPage(rows: ShipmentSummary[], overrides: Partial = {}
} as never)
}
+// Lo tipeado viaja con un debounce de 300 ms (`useDebouncedValue`): hay que
+// dejarlo vencer para afirmar sobre lo que se le pidió a la API.
+const SETTLE_MS = 400
+
+function settle() {
+ return act(() => new Promise((resolve) => setTimeout(resolve, SETTLE_MS)))
+}
+
+/** Los filtros del último pedido del listado. */
+function lastFilters() {
+ const calls = vi.mocked(useShipmentPage).mock.calls
+
+ return calls[calls.length - 1][0]
+}
+
function renderPage() {
renderWithTheme(
@@ -148,6 +163,72 @@ describe('ShipmentsPage', () => {
expect(screen.getByText('Mostrando 0 a 0 de 0 envíos')).toBeInTheDocument()
})
+ // El buscador del listado (TESIS-164).
+ describe('search', () => {
+ function type(term: string) {
+ fireEvent.change(screen.getByRole('textbox', { name: 'Buscar' }), {
+ target: { value: term },
+ })
+
+ return settle()
+ }
+
+ it('sends the typed term to the API', async () => {
+ renderPage()
+ await type('AND-9920')
+
+ expect(lastFilters().search).toBe('AND-9920')
+ })
+
+ // Sin el debounce cada tecla es un request: escribir el seguimiento entero
+ // son dieciséis, de los que quince se descartan.
+ it('does not send anything until the typing settles', () => {
+ renderPage()
+ fireEvent.change(screen.getByRole('textbox', { name: 'Buscar' }), {
+ target: { value: 'AND' },
+ })
+
+ expect(lastFilters().search).toBe('')
+ })
+
+ // Buscar parado en la página 3 devolvía una página vacía: el resultado de
+ // la búsqueda entra en una sola.
+ it('goes back to the first page when the term changes', async () => {
+ mockPage([DISPATCHED, PENDING], { total: 45 })
+ renderPage()
+ fireEvent.click(screen.getByRole('button', { name: 'Página siguiente' }))
+ expect(lastFilters().page).toBe(2)
+
+ await type('AND')
+
+ expect(lastFilters().page).toBe(1)
+ })
+
+ // Las pestañas tienen que contar lo mismo que muestra la tabla.
+ it('counts the tabs with the term applied', async () => {
+ renderPage()
+ await type('AND')
+
+ expect(vi.mocked(useShipmentCounts)).toHaveBeenLastCalledWith('AND')
+ })
+
+ // Buscar y no encontrar no es lo mismo que una pestaña vacía.
+ it('says nothing matched instead of showing the empty tab message', async () => {
+ mockPage([], { total: 0 })
+ renderPage()
+ await type('nada')
+
+ expect(screen.getByText('Ningún envío coincide con la búsqueda.')).toBeInTheDocument()
+ })
+
+ it('keeps the empty tab message when nobody searched', () => {
+ mockPage([], { total: 0 })
+ renderPage()
+
+ expect(screen.getByText('No hay envíos para este filtro.')).toBeInTheDocument()
+ })
+ })
+
it('offers to retry when the listing fails', () => {
const refetch = vi.fn()
vi.mocked(useShipmentPage).mockReturnValue({
diff --git a/src/features/shipments/pages/ShipmentsPage.tsx b/src/features/shipments/pages/ShipmentsPage.tsx
index 404bb1f..8008157 100644
--- a/src/features/shipments/pages/ShipmentsPage.tsx
+++ b/src/features/shipments/pages/ShipmentsPage.tsx
@@ -1,8 +1,10 @@
-import { Box, Stack, Typography } from '@mui/material'
+import SearchIcon from '@mui/icons-material/Search'
+import { Box, InputAdornment, Stack, TextField, Typography } from '@mui/material'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components'
import type { DataTableTab } from 'shared/components'
+import { useDebouncedValue } from 'shared/hooks/useDebouncedValue'
import { ShipmentsTable } from '../components/ShipmentsTable'
import { formatCount, shipmentsCopy } from '../content'
@@ -10,7 +12,7 @@ import { SHIPMENT_TABS, useShipmentCounts, useShipmentPage } from '../hooks/useS
import type { ShipmentTabId } from '../hooks/useShipments'
import type { ShipmentSummary } from '../types'
-const { page: pageCopy, tabs: tabCopy, pagination } = shipmentsCopy
+const { page: pageCopy, tabs: tabCopy, table, pagination } = shipmentsCopy
const PER_PAGE = 20
@@ -42,18 +44,36 @@ export function ShipmentsPage() {
const navigate = useNavigate()
const [tabId, setTabId] = useState('all')
const [page, setPage] = useState(1)
+ const [search, setSearch] = useState('')
+
+ // Lo que se tipea actualiza el campo en el acto; lo que viaja a la API espera
+ // a que la persona deje de escribir.
+ const debouncedSearch = useDebouncedValue(search)
const status = SHIPMENT_TABS.find((tab) => tab.id === tabId)?.status
- const shipments = useShipmentPage({ page, perPage: PER_PAGE, status })
- const counts = useShipmentCounts()
+ const shipments = useShipmentPage({
+ page,
+ perPage: PER_PAGE,
+ status,
+ search: debouncedSearch,
+ })
+ // El término también va a los contadores: si no, las pestañas seguirían
+ // diciendo cuántos envíos tiene la empresa mientras la tabla muestra tres.
+ const counts = useShipmentCounts(debouncedSearch)
- // Cambiar de pestaña vuelve a la primera página: quedarse en la 7 de un
- // filtro que ahora tiene 2 páginas deja la tabla vacía sin motivo visible.
+ // Cambiar de pestaña o de búsqueda vuelve a la primera página: quedarse en la
+ // 7 de un filtro que ahora tiene 2 páginas deja la tabla vacía sin motivo
+ // visible.
function changeTab(nextTabId: string) {
setTabId(nextTabId as ShipmentTabId)
setPage(1)
}
+ function changeSearch(value: string) {
+ setSearch(value)
+ setPage(1)
+ }
+
const tabs: DataTableTab[] = SHIPMENT_TABS.map(({ id }, index) => ({
id,
label: TAB_LABELS[id],
@@ -85,14 +105,41 @@ export function ShipmentsPage() {
return (
-
-
- {pageCopy.title}
-
-
- {pageCopy.subtitle}
-
-
+ {/* `useFlexGap`: sin él `spacing` separa con `margin-left` y pisa el
+ `ml: 'auto'` que manda el buscador a la derecha (TESIS-132). */}
+
+
+
+ {pageCopy.title}
+
+
+ {pageCopy.subtitle}
+
+
+
+ changeSearch(event.target.value)}
+ label={pageCopy.searchLabel}
+ placeholder={pageCopy.searchPlaceholder}
+ sx={{ ml: { md: 'auto' }, width: { xs: '100%', sm: 300 }, flexShrink: 0 }}
+ slotProps={{
+ input: {
+ startAdornment: (
+
+
+
+ ),
+ },
+ }}
+ />
+ [...shipmentKeys.all, 'count'] as const,
// `status ?? 'all'`: sin esto, la clave de «todos» y la de un estado
// indefinido serían la misma sólo por casualidad de serialización.
- count: (status: ShipmentStatus | undefined) =>
- [...shipmentKeys.counts(), status ?? 'all'] as const,
+ // El término entra en la clave del contador igual que en la del listado: si
+ // no, las pestañas seguirían contando la empresa entera mientras la tabla
+ // muestra lo buscado.
+ count: (status: ShipmentStatus | undefined, search: string) =>
+ [...shipmentKeys.counts(), status ?? 'all', search] as const,
}
diff --git a/src/features/shipments/types.ts b/src/features/shipments/types.ts
index c04cad9..8be5031 100644
--- a/src/features/shipments/types.ts
+++ b/src/features/shipments/types.ts
@@ -43,4 +43,6 @@ export interface ShipmentFilters {
status?: ShipmentStatus
/** Filtra los envíos de una orden concreta. */
orderId?: number
+ /** Número de seguimiento o id del canal. Vacío es «sin buscar». */
+ search?: string
}
From 43b1a24b9547354f2482dae4a948e3eba920cf24 Mon Sep 17 00:00:00 2001
From: Tomas Martin
Date: Thu, 8 Oct 2026 22:40:01 -0300
Subject: [PATCH 08/10] =?UTF-8?q?fix:=20[TESIS-163]=20stop=20offering=20?=
=?UTF-8?q?=C2=ABCrear=20env=C3=ADo=C2=BB=20on=20an=20order=20picked=20up?=
=?UTF-8?q?=20at=20the=20store?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 🔴 of the review. The lifecycle card said the right thing -- "El cliente
retira esta orden en el local. No lleva envío." -- and put a «Crear envío»
button next to it. Pressing it answered 422 (`PickupOrderError`, api#115) and
the operator got the generic "No pudimos crear el envío de la orden."
It is the first acceptance criterion of the card that touches pickups: an
order picked up at the store does not offer to create a shipment.
Neither branch introduced it on its own. TESIS-141 (#77) added the button with
`canOpenShipment(status, view)`, which looks at the order status and whether
the shipment exists; this branch added `requiresShipping`. Git saw no conflict
because each side touched different lines, and the suite passed with the button
on screen.
`canOpenShipment` now takes `requiresShipping`, which keeps both exclusions in
the one place that mirrors `Shipments::CreateShipment`: a cancelled order
answers `CancelledOrderError` and a pickup `PickupOrderError`, both 422. Making
the page drop the action instead would have split the rule in two.
Two specs pin it, and each fails on its own when the condition is removed: the
unit one on the helper, and one on the detail screen, which is where the button
was actually reachable.
837 tests, 0 failures. ESLint and `tsc -b` clean.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp
---
.../orders/pages/OrderDetailPage.test.tsx | 21 +++++++++++++++++++
src/features/orders/pages/OrderDetailPage.tsx | 2 +-
src/features/orders/utils/dispatch.test.ts | 20 +++++++++++-------
src/features/orders/utils/dispatch.ts | 20 +++++++++++++-----
4 files changed, 50 insertions(+), 13 deletions(-)
diff --git a/src/features/orders/pages/OrderDetailPage.test.tsx b/src/features/orders/pages/OrderDetailPage.test.tsx
index 83de9e8..3aaa612 100644
--- a/src/features/orders/pages/OrderDetailPage.test.tsx
+++ b/src/features/orders/pages/OrderDetailPage.test.tsx
@@ -251,6 +251,27 @@ describe('OrderDetailPage', () => {
).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()
+ })
+
it('still says the shipment is missing for an order that is shipped', () => {
mockQueries({ data: ORDER }, { data: { kind: 'none' } })
diff --git a/src/features/orders/pages/OrderDetailPage.tsx b/src/features/orders/pages/OrderDetailPage.tsx
index 9cf5715..b134738 100644
--- a/src/features/orders/pages/OrderDetailPage.tsx
+++ b/src/features/orders/pages/OrderDetailPage.tsx
@@ -233,7 +233,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
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'
}
/**
From 595f3c65d30ec00f7f9a401371cef53943ae6265 Mon Sep 17 00:00:00 2001
From: Tomas Martin
Date: Thu, 8 Oct 2026 23:04:07 -0300
Subject: [PATCH 09/10] fix: [TESIS-163] close the two screen findings of the
review
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both are the same confusion the card came to remove: a word that means one
thing in one place and another thing next to it.
**The shipping panel of an order that carries no shipment.** The lifecycle card
said «El cliente retira esta orden en el local. No lleva envío.» and the column
beside it kept announcing «Número de seguimiento: Pendiente de despacho» and
«Etiqueta de envío: Se emite al despachar» -- three statements about something
that is never going to exist. The panel is for an order that ships, so a pickup
does not render it, and the lifecycle card is left to explain why.
The payment summary had the same problem in one line: «Envío: Sin cotizar»
reads as a price that has not arrived yet. A pickup will not be quoted ever, so
the row says «Retiro en el local».
**«En depósito» meant two things on the same screen.** The headline of the
stock card is `onHand` -- physical units, which TESIS-162 defines as free plus
committed -- and the distribution table used the same words for a column that
showed only the free ones. On NOR-005 the headline read 44 while its rows added
up to 40, and the Depósito Central row said 15 with 4 of its units sold and
still on its shelf.
`DistributionPosition` now carries `onHand` next to `quantity`, so the column
and the headline read the same number from the same definition. A warehouse
with no stock row keeps its committed units as its whole physical count: the
sale took its last free unit, the goods are still there.
Each fix is pinned on its own. Removing the panel guard fails only "hides the
shipping panel of an order picked up at the store". Turning the summary's
pickup flag off fails only "says the pickup has no shipping cost instead of
leaving it unquoted". Putting `onHand` back to `quantity` fails the two that
count physical units and add the rows up to the headline.
842 tests, 0 failures. ESLint and `tsc -b` clean.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp
---
.../inventory/pages/ProductDetailPage.tsx | 4 ++-
src/features/inventory/utils/stock.test.ts | 31 +++++++++++++++++
src/features/inventory/utils/stock.ts | 14 ++++++++
.../PaymentSummaryCard/PaymentSummaryCard.tsx | 10 ++++--
.../PaymentSummaryCard.types.ts | 6 ++++
src/features/orders/content.ts | 2 ++
.../orders/pages/OrderDetailPage.test.tsx | 29 ++++++++++++++++
src/features/orders/pages/OrderDetailPage.tsx | 33 +++++++++++--------
8 files changed, 113 insertions(+), 16 deletions(-)
diff --git a/src/features/inventory/pages/ProductDetailPage.tsx b/src/features/inventory/pages/ProductDetailPage.tsx
index 2bc7d12..12f24cd 100644
--- a/src/features/inventory/pages/ProductDetailPage.tsx
+++ b/src/features/inventory/pages/ProductDetailPage.tsx
@@ -163,7 +163,9 @@ function buildRows(product: Product): WarehouseDistributionRow[] {
location: position.location ?? specsCopy.unknown,
committed: formatUnits(position.committed),
inTransit: formatUnits(position.incoming),
- onHand: formatUnits(position.quantity),
+ // 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: formatUnits(position.onHand),
statusLabel: stockLabel(position.stockStatus),
statusVariant: stockVariant(position.stockStatus),
critical: stockRowTone(position.stockStatus) === 'critical',
diff --git a/src/features/inventory/utils/stock.test.ts b/src/features/inventory/utils/stock.test.ts
index c3ab782..6305f56 100644
--- a/src/features/inventory/utils/stock.test.ts
+++ b/src/features/inventory/utils/stock.test.ts
@@ -110,6 +110,7 @@ describe('distributionPositions', () => {
quantity: 0,
incoming: 4,
committed: 0,
+ onHand: 0,
stockStatus: 'out_of_stock',
})
})
@@ -132,6 +133,9 @@ describe('distributionPositions', () => {
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',
})
})
@@ -165,6 +169,33 @@ describe('distributionPositions', () => {
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 7c0ce41..e8f5b4d 100644
--- a/src/features/inventory/utils/stock.ts
+++ b/src/features/inventory/utils/stock.ts
@@ -28,11 +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
}
@@ -68,6 +78,7 @@ export function distributionPositions(product: Product): DistributionPosition[]
quantity: stock.quantity,
incoming: incoming.get(stock.warehouseId) ?? 0,
committed: stock.committed,
+ onHand: stock.quantity + stock.committed,
stockStatus: stock.stockStatus,
}))
@@ -102,6 +113,9 @@ export function distributionPositions(product: Product): DistributionPosition[]
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,
}))
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/content.ts b/src/features/orders/content.ts
index bc59817..eb11ef4 100644
--- a/src/features/orders/content.ts
+++ b/src/features/orders/content.ts
@@ -188,6 +188,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.',
diff --git a/src/features/orders/pages/OrderDetailPage.test.tsx b/src/features/orders/pages/OrderDetailPage.test.tsx
index 3aaa612..2ea4b85 100644
--- a/src/features/orders/pages/OrderDetailPage.test.tsx
+++ b/src/features/orders/pages/OrderDetailPage.test.tsx
@@ -272,6 +272,35 @@ describe('OrderDetailPage', () => {
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' } })
diff --git a/src/features/orders/pages/OrderDetailPage.tsx b/src/features/orders/pages/OrderDetailPage.tsx
index b134738..dfdfc56 100644
--- a/src/features/orders/pages/OrderDetailPage.tsx
+++ b/src/features/orders/pages/OrderDetailPage.tsx
@@ -288,23 +288,30 @@ export function OrderDetailPage() {
fields={customerFields(order.data)}
footnote={detail.customer.footnote}
/>
- }
- 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}
From ba11f7bf30e66532fbb96b0f70f13676c55ed161 Mon Sep 17 00:00:00 2001
From: Tomas Martin <128748466+TomasMartin2004@users.noreply.github.com>
Date: Sat, 10 Oct 2026 17:04:33 -0300
Subject: [PATCH 10/10] refactor: [TESIS-166] leave one number formatter
instead of eleven copies (#81)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Seven features each wrote their own. `formatUnits` in inventory and warehouses,
`formatCount` in orders, shipments and failed events, `formatMoney` in orders,
shipments and the dashboard, plus three loose `Intl.NumberFormat` in the
dashboard and in reports. Same locale, same options, different names.
`shared/utils/number.ts` holds the four the product actually has:
- `formatInteger` -- "4.280", what `formatUnits` and `formatCount` both were
- `formatDecimal` -- "1,45", a weight or a measure
- `formatMoney` -- "$ 1.478.300,49", with cents, for billed money
- `formatRoundMoney` -- "$ 58.300", for the shipping cost read at a glance
**The copies did not agree.** Only one of them -- inventory's, from a review of
TESIS-163 -- refused to print `NaN` when the API did not send a field the
screen already reads. The other ten printed it. Now every screen inherits the
guard, and the four shared functions answer "—" for anything that is not a
finite number.
`features/dashboard/utils/recentOrders.ts` carried a comment saying the
duplication with `features/orders/utils` was deliberate, that a feature cannot
import from another feature (architecture.md §3.2), and that converging the two
copies was a follow-up card. This is that card; the comment is updated to say
so. The status labels stay duplicated on purpose: those are screen vocabulary,
not formatting.
**What is not a copy and stays.** The weight of an order (one decimal and a
"kg" suffix), the compact notation of reports ("7,5k", "4,2 MM") and the trend
chip of `StatCard` (`signDisplay: 'exceptZero'`). Three different formats, not
three copies of one.
Their examples moved to `shared/utils/number.test.ts`, out of the three feature
suites that each tested its own copy.
Of fifteen `Intl.NumberFormat` in `src/`, seven are left: the four of the
shared module and those three.
88 files, 841 tests, 0 failures. ESLint, `tsc -b` and the build clean.
Depends on proyecto-web#79 (TESIS-163): the shipments feature, which holds two
of the copies, lands with it.
Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp
Co-authored-by: Claude Opus 5 (1M context)
---
.../RecentOrdersTable/RecentOrdersTable.tsx | 2 +-
src/features/dashboard/content.ts | 18 ++--
.../dashboard/pages/DashboardPage.tsx | 5 +-
.../dashboard/utils/recentOrders.test.ts | 22 +----
src/features/dashboard/utils/recentOrders.ts | 30 ++-----
src/features/failed-events/content.ts | 9 +-
.../failed-events/pages/FailedEventsPage.tsx | 12 +--
.../InventoryTable/InventoryTable.tsx | 5 +-
src/features/inventory/content.ts | 11 +--
.../inventory/pages/InventoryPage.tsx | 5 +-
.../inventory/pages/ProductDetailPage.tsx | 25 +++---
src/features/inventory/utils/format.test.ts | 37 +-------
src/features/inventory/utils/format.ts | 28 ------
.../DraftItemsTable/DraftItemsTable.tsx | 3 +-
.../EditLinesTable/EditLinesTable.tsx | 2 +-
.../OrderConfirmCard/OrderConfirmCard.tsx | 3 +-
.../OrderItemsTable/OrderItemsTable.tsx | 6 +-
.../components/OrdersTable/OrdersTable.tsx | 3 +-
.../ProductPicker/ProductPicker.tsx | 5 +-
.../QuoteOptionList/QuoteOptionList.tsx | 2 +-
src/features/orders/components/RecalcCard.tsx | 2 +-
src/features/orders/content.ts | 25 +++---
src/features/orders/pages/NewOrderPage.tsx | 3 +-
src/features/orders/pages/OrderDetailPage.tsx | 7 +-
src/features/orders/pages/OrdersPage.tsx | 5 +-
src/features/orders/utils/format.test.ts | 27 +-----
src/features/orders/utils/format.ts | 18 ----
.../ReportMetrics/ReportMetrics.tsx | 3 +-
src/features/reports/utils/format.test.ts | 18 +---
src/features/reports/utils/format.ts | 10 +--
.../ShipmentsTable/ShipmentsTable.tsx | 5 +-
src/features/shipments/content.ts | 18 +---
.../shipments/pages/ShipmentsPage.tsx | 5 +-
src/features/warehouses/content.ts | 6 --
.../warehouses/pages/WarehousesPage.tsx | 5 +-
src/shared/utils/index.ts | 1 +
src/shared/utils/number.test.ts | 89 +++++++++++++++++++
src/shared/utils/number.ts | 77 ++++++++++++++++
38 files changed, 271 insertions(+), 286 deletions(-)
create mode 100644 src/shared/utils/number.test.ts
create mode 100644 src/shared/utils/number.ts
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/content.ts b/src/features/dashboard/content.ts
index a63626a..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.',
@@ -43,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',
@@ -65,16 +63,16 @@ export const dashboardCopy = {
warehouses: {
title: 'Carga por depósito',
caption: (units: number) =>
- `${UNITS_FORMAT.format(units)} ${units === 1 ? 'unidad guardada' : 'unidades guardadas'}`,
- 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}: ${UNITS_FORMAT.format(units)} de ${UNITS_FORMAT.format(capacity)} unidades de capacidad`,
+ `${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.
diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx
index e24f75e..a2dff97 100644
--- a/src/features/dashboard/pages/DashboardPage.tsx
+++ b/src/features/dashboard/pages/DashboardPage.tsx
@@ -5,6 +5,7 @@ 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 { RecentOrdersTable } from '../components/RecentOrdersTable'
import { RecentShipmentsCard } from '../components/RecentShipmentsCard'
@@ -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() {
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/components/InventoryTable/InventoryTable.tsx b/src/features/inventory/components/InventoryTable/InventoryTable.tsx
index d2df7ed..ff21f22 100644
--- a/src/features/inventory/components/InventoryTable/InventoryTable.tsx
+++ b/src/features/inventory/components/InventoryTable/InventoryTable.tsx
@@ -4,8 +4,9 @@ import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
import { Box, Link, Typography } from '@mui/material'
import { DataTable, StatusBadge } from 'shared/components'
import type { DataTableAction, DataTableColumn } from 'shared/components'
+import { formatInteger } from 'shared/utils'
-import { formatUnits, inventoryCopy } from '../../content'
+import { inventoryCopy } from '../../content'
import type { ProductSummary } from '../../types'
import { stockLabel, stockRowTone, stockVariant } from '../../utils/stockStatus'
@@ -78,7 +79,7 @@ function buildColumns(
// en ningún nodo. Sumarlas diría que hay mercadería que no está.
render: (product) => (
- {formatUnits(product.totalStock)}
+ {formatInteger(product.totalStock)}
{product.inTransitQuantity > 0 ? (
{cells.inTransit(product.inTransitQuantity)}
diff --git a/src/features/inventory/content.ts b/src/features/inventory/content.ts
index b216af9..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: {
@@ -279,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/pages/InventoryPage.tsx b/src/features/inventory/pages/InventoryPage.tsx
index a9e91ec..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,
@@ -163,7 +164,7 @@ export function InventoryPage() {
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.data === undefined ? undefined : formatUnits(counts.data[id]),
+ count: counts.data === undefined ? undefined : formatInteger(counts.data[id]),
}))
return (
diff --git a/src/features/inventory/pages/ProductDetailPage.tsx b/src/features/inventory/pages/ProductDetailPage.tsx
index 12f24cd..ac7e41a 100644
--- a/src/features/inventory/pages/ProductDetailPage.tsx
+++ b/src/features/inventory/pages/ProductDetailPage.tsx
@@ -6,6 +6,7 @@ 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'
@@ -28,7 +29,7 @@ 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'
@@ -70,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,
},
{
@@ -78,9 +79,9 @@ 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,
@@ -134,19 +135,19 @@ function buildBuckets(product: Product): StockBucket[] {
id: 'committed',
label: masterCopy.buckets.committed,
icon: ,
- value: formatUnits(product.committed),
+ 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: formatUnits(product.availableToPromise),
+ value: formatInteger(product.availableToPromise),
accent: true,
},
]
@@ -161,11 +162,11 @@ function buildRows(product: Product): WarehouseDistributionRow[] {
id: position.warehouseId,
name: position.name,
location: position.location ?? specsCopy.unknown,
- committed: formatUnits(position.committed),
- inTransit: formatUnits(position.incoming),
+ 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: formatUnits(position.onHand),
+ onHand: formatInteger(position.onHand),
statusLabel: stockLabel(position.stockStatus),
statusVariant: stockVariant(position.stockStatus),
critical: stockRowTone(position.stockStatus) === 'critical',
@@ -316,7 +317,7 @@ export function ProductDetailPage() {
depósito puede quedarse sin fila de stock y seguir teniendo
unidades vendidas sin despachar. */}
openEditor('stock')}
diff --git a/src/features/inventory/utils/format.test.ts b/src/features/inventory/utils/format.test.ts
index 9f75c01..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', () => {
@@ -40,16 +24,3 @@ describe('formatSpecTimestamp', () => {
expect(formatSpecTimestamp(isoDate)).toBe(`${expectedDate} · ${expectedTime}`)
})
})
-
-// Lo que la API manda es lo que la pantalla imprime, y si una versión vieja no
-// manda un campo que esta pantalla ya lee, `Intl` escribe `NaN`.
-describe('formatUnits with something that is not a number', () => {
- it('says there is no data instead of printing NaN', () => {
- expect(formatUnits(undefined as unknown as number)).toBe('—')
- expect(formatUnits(Number.NaN)).toBe('—')
- })
-
- it('still formats a zero, which is a number like any other', () => {
- expect(formatUnits(0)).toBe('0')
- })
-})
diff --git a/src/features/inventory/utils/format.ts b/src/features/inventory/utils/format.ts
index 3c57b6f..c94417c 100644
--- a/src/features/inventory/utils/format.ts
+++ b/src/features/inventory/utils/format.ts
@@ -7,34 +7,6 @@
import { formatDate } from 'shared/utils'
-// La marca de «sin dato» del DS, la misma que usa el resto del producto.
-const UNKNOWN_VALUE = '—'
-
-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"`.
- *
- * Un valor que no es un número finito sale como «—» y no como `NaN`.
- * TypeScript dice que no puede pasar, pero el dato viene de la API: si una
- * versión más vieja no manda un campo que esta pantalla ya lee —un front
- * desplegado antes que su backend—, `Intl` imprime `NaN` en la cara del
- * operador. «—» dice lo mismo que diría cualquier otro dato que falta.
- */
-export function formatUnits(value: number): string {
- return Number.isFinite(value) ? integerFormatter.format(value) : UNKNOWN_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/orders/components/DraftItemsTable/DraftItemsTable.tsx b/src/features/orders/components/DraftItemsTable/DraftItemsTable.tsx
index 6c27b25..28c23ea 100644
--- a/src/features/orders/components/DraftItemsTable/DraftItemsTable.tsx
+++ b/src/features/orders/components/DraftItemsTable/DraftItemsTable.tsx
@@ -3,9 +3,10 @@ import { Typography } from '@mui/material'
import { DataTable } from 'shared/components'
import type { DataTableAction, DataTableColumn } from 'shared/components'
import type { OrderDraftItem } from 'shared/store'
+import { formatMoney } from 'shared/utils'
import { ordersCopy } from '../../content'
-import { formatMoney, formatWeight } from '../../utils/format'
+import { formatWeight } from '../../utils/format'
import { lineSubtotal } from '../../utils/payment'
import type { DraftItemsTableProps } from './DraftItemsTable.types'
diff --git a/src/features/orders/components/EditLinesTable/EditLinesTable.tsx b/src/features/orders/components/EditLinesTable/EditLinesTable.tsx
index 262d458..39e75c3 100644
--- a/src/features/orders/components/EditLinesTable/EditLinesTable.tsx
+++ b/src/features/orders/components/EditLinesTable/EditLinesTable.tsx
@@ -4,11 +4,11 @@ import WarningAmberIcon from '@mui/icons-material/WarningAmber'
import { Box, IconButton, Stack, Tooltip, Typography } from '@mui/material'
import { DataTable } from 'shared/components'
import type { DataTableColumn } from 'shared/components'
+import { formatMoney } from 'shared/utils'
import { ordersCopy } from '../../content'
import { isLocked, isQuantityValid } from '../../utils/edit'
import type { EditLine, StockShortfall } from '../../utils/edit'
-import { formatMoney } from '../../utils/format'
import { lineSubtotal } from '../../utils/payment'
import { StockWarnings } from './EditLinesTable.styles'
diff --git a/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.tsx b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.tsx
index 9c86943..1ce9775 100644
--- a/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.tsx
+++ b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.tsx
@@ -2,9 +2,10 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import { Alert, Button, Divider, Stack, Typography } from '@mui/material'
import { useId } from 'react'
+import { formatMoney } from 'shared/utils'
import { ordersCopy } from '../../content'
-import { formatMoney, formatWeight } from '../../utils/format'
+import { formatWeight } from '../../utils/format'
import { totalWithShipping } from '../../utils/shipping'
import { ConfirmCardRoot, SummaryLine, TotalValue } from './OrderConfirmCard.styles'
diff --git a/src/features/orders/components/OrderItemsTable/OrderItemsTable.tsx b/src/features/orders/components/OrderItemsTable/OrderItemsTable.tsx
index c11ee73..8b84b38 100644
--- a/src/features/orders/components/OrderItemsTable/OrderItemsTable.tsx
+++ b/src/features/orders/components/OrderItemsTable/OrderItemsTable.tsx
@@ -2,10 +2,10 @@ import { Link, Typography } from '@mui/material'
import { Link as RouterLink } from 'react-router-dom'
import { DataTable } from 'shared/components'
import type { DataTableColumn } from 'shared/components'
+import { formatInteger, formatMoney } from 'shared/utils'
-import { formatCount, ordersCopy } from '../../content'
+import { ordersCopy } from '../../content'
import type { OrderLine } from '../../types'
-import { formatMoney } from '../../utils/format'
import { lineSubtotal, totalUnits } from '../../utils/payment'
import type { OrderItemsTableProps } from './OrderItemsTable.types'
@@ -61,7 +61,7 @@ function buildColumns(
header: itemsCopy.columns.quantity,
align: 'right',
width: 90,
- render: (line) => {formatCount(line.quantity)},
+ render: (line) => {formatInteger(line.quantity)},
},
{
id: 'subtotal',
diff --git a/src/features/orders/components/OrdersTable/OrdersTable.tsx b/src/features/orders/components/OrdersTable/OrdersTable.tsx
index af34e43..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'
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/content.ts b/src/features/orders/content.ts
index eb11ef4..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',
@@ -133,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',
@@ -206,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'
}`,
},
@@ -280,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',
@@ -305,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. */
@@ -359,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:
@@ -495,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',
@@ -539,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/pages/NewOrderPage.tsx b/src/features/orders/pages/NewOrderPage.tsx
index 1c3b8ae..e77ce1a 100644
--- a/src/features/orders/pages/NewOrderPage.tsx
+++ b/src/features/orders/pages/NewOrderPage.tsx
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom'
import { PageWrapper } from 'shared/components'
import { useDebouncedValue } from 'shared/hooks/useDebouncedValue'
import { useOrderDraftStore } from 'shared/store'
+import { formatMoney } from 'shared/utils'
import { CustomerFieldsCard, customerSchema } from '../components/CustomerFieldsCard'
import type { CustomerFormData } from '../components/CustomerFieldsCard'
@@ -18,7 +19,7 @@ import { ProductPicker } from '../components/ProductPicker'
import { ordersCopy } from '../content'
import { useCatalogProducts } from '../hooks/useCatalogProducts'
import { canProceed, draftSubtotal, draftWeight } from '../utils/draft'
-import { formatMoney, formatWeight } from '../utils/format'
+import { formatWeight } from '../utils/format'
const { wizard, draft } = ordersCopy
diff --git a/src/features/orders/pages/OrderDetailPage.tsx b/src/features/orders/pages/OrderDetailPage.tsx
index dfdfc56..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: ,
},
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/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/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/components/ShipmentsTable/ShipmentsTable.tsx b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx
index 97e40e4..d9f76a5 100644
--- a/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx
+++ b/src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx
@@ -2,8 +2,9 @@ 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 { formatMoney, shipmentsCopy } from '../../content'
+import { shipmentsCopy } from '../../content'
import type { ShipmentStatus, ShipmentSummary } from '../../types'
import { formatShipmentDate, formatShipmentTime } from '../../utils/format'
@@ -94,7 +95,7 @@ function buildColumns(
shipment.shippingCost === null ? (
{cells.noCost}
) : (
- {formatMoney(shipment.shippingCost)}
+ {formatRoundMoney(shipment.shippingCost)}
),
},
{
diff --git a/src/features/shipments/content.ts b/src/features/shipments/content.ts
index e09b0c9..e8511ec 100644
--- a/src/features/shipments/content.ts
+++ b/src/features/shipments/content.ts
@@ -1,12 +1,6 @@
// Copy centralizado de la feature — sin literales sueltos en el JSX.
-const UNITS_FORMAT = new Intl.NumberFormat('es-AR')
-
-const MONEY_FORMAT = new Intl.NumberFormat('es-AR', {
- style: 'currency',
- currency: 'ARS',
- maximumFractionDigits: 0,
-})
+import { formatInteger } from 'shared/utils'
export const shipmentsCopy = {
page: {
@@ -66,14 +60,6 @@ export const shipmentsCopy = {
next: 'Página siguiente',
page: (number: number) => `Página ${number}`,
summary: (from: number, to: number, total: number) =>
- `Mostrando ${UNITS_FORMAT.format(from)} a ${UNITS_FORMAT.format(to)} de ${UNITS_FORMAT.format(total)} ${total === 1 ? 'envío' : 'envíos'}`,
+ `Mostrando ${formatInteger(from)} a ${formatInteger(to)} de ${formatInteger(total)} ${total === 1 ? 'envío' : 'envíos'}`,
},
} as const
-
-export function formatCount(value: number): string {
- return UNITS_FORMAT.format(value)
-}
-
-export function formatMoney(value: number): string {
- return MONEY_FORMAT.format(value)
-}
diff --git a/src/features/shipments/pages/ShipmentsPage.tsx b/src/features/shipments/pages/ShipmentsPage.tsx
index 8008157..13f52db 100644
--- a/src/features/shipments/pages/ShipmentsPage.tsx
+++ b/src/features/shipments/pages/ShipmentsPage.tsx
@@ -5,9 +5,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 { ShipmentsTable } from '../components/ShipmentsTable'
-import { formatCount, shipmentsCopy } from '../content'
+import { shipmentsCopy } from '../content'
import { SHIPMENT_TABS, useShipmentCounts, useShipmentPage } from '../hooks/useShipments'
import type { ShipmentTabId } from '../hooks/useShipments'
import type { ShipmentSummary } from '../types'
@@ -79,7 +80,7 @@ export function ShipmentsPage() {
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 (shipments.isPending) return
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/utils/index.ts b/src/shared/utils/index.ts
index c51ddd6..e35e958 100644
--- a/src/shared/utils/index.ts
+++ b/src/shared/utils/index.ts
@@ -23,3 +23,4 @@ export function isNonEmpty(value: T | null | undefined): value is T {
}
export { formatRelativeTime } from './relativeTime'
+export { formatDecimal, formatInteger, formatMoney, formatRoundMoney } from './number'
diff --git a/src/shared/utils/number.test.ts b/src/shared/utils/number.test.ts
new file mode 100644
index 0000000..7a6e3a1
--- /dev/null
+++ b/src/shared/utils/number.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest'
+
+import { formatDecimal, formatInteger, formatMoney, formatRoundMoney } from './number'
+
+// Estos ejemplos venían repartidos en `inventory/utils/format.test.ts`,
+// `orders/utils/format.test.ts` y `reports/utils/format.test.ts`, uno por cada
+// copia del mismo formateador. Al quedar una sola implementación (TESIS-166),
+// quedan en un solo lugar y valen para todas las pantallas.
+
+describe('formatInteger', () => {
+ it('uses a thousands separator, as the design writes it', () => {
+ expect(formatInteger(4280)).toBe('4.280')
+ })
+
+ it('has no separator below one thousand', () => {
+ expect(formatInteger(280)).toBe('280')
+ })
+
+ it('still formats a zero, which is a number like any other', () => {
+ expect(formatInteger(0)).toBe('0')
+ })
+
+ // Lo que la API manda es lo que la pantalla imprime, y si una versión vieja
+ // no manda un campo que la pantalla ya lee, `Intl` escribe `NaN`. La guarda
+ // existía en una sola de las copias; ahora la heredan todas.
+ it('says there is no data instead of printing NaN', () => {
+ expect(formatInteger(undefined as unknown as number)).toBe('—')
+ expect(formatInteger(Number.NaN)).toBe('—')
+ expect(formatInteger(Number.POSITIVE_INFINITY)).toBe('—')
+ })
+})
+
+describe('formatDecimal', () => {
+ it('formats with a comma decimal separator', () => {
+ expect(formatDecimal(1.45)).toBe('1,45')
+ })
+
+ it('pads to two decimals', () => {
+ expect(formatDecimal(2)).toBe('2,00')
+ })
+
+ it('says there is no data instead of printing NaN', () => {
+ expect(formatDecimal(Number.NaN)).toBe('—')
+ })
+})
+
+describe('formatMoney', () => {
+ it('groups thousands with a dot', () => {
+ expect(formatMoney(1478300)).toContain('1.478.300')
+ })
+
+ /**
+ * El diseño muestra los importes sin decimales, pero donde se usa 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')
+ })
+
+ it('says there is no data instead of printing NaN', () => {
+ expect(formatMoney(Number.NaN)).toBe('—')
+ })
+})
+
+describe('formatRoundMoney', () => {
+ // El costo del envío se lee de un vistazo en una tabla: el centavo no cambia
+ // ninguna decisión y la columna gana en lectura.
+ it('drops the cents', () => {
+ expect(formatRoundMoney(58300)).toContain('58.300')
+ expect(formatRoundMoney(58300)).not.toContain(',')
+ })
+
+ it('rounds instead of truncating', () => {
+ expect(formatRoundMoney(58300.6)).toContain('58.301')
+ })
+
+ it('says there is no data instead of printing NaN', () => {
+ expect(formatRoundMoney(Number.NaN)).toBe('—')
+ })
+})
diff --git a/src/shared/utils/number.ts b/src/shared/utils/number.ts
new file mode 100644
index 0000000..268017c
--- /dev/null
+++ b/src/shared/utils/number.ts
@@ -0,0 +1,77 @@
+// El único lugar donde la app decide cómo se escribe un número.
+//
+// Locale fijo `es-AR`, mismo criterio que `formatDate`: el producto está entero
+// en español (ver el vocabulario en `docs/design/README.md`) y todavía no hay
+// i18n. El diseño escribe los enteros con punto de miles («4.280») y los
+// decimales con coma («1,45 kg»).
+//
+// Hasta TESIS-166 cada feature tenía su copia: `formatUnits` en inventario y
+// depósitos, `formatCount` en órdenes, envíos y eventos fallidos, y tres
+// `Intl.NumberFormat` sueltos más. Eran el mismo formateador con distinto
+// nombre, y sólo una de las copias se protegía de un valor no finito.
+
+/** La marca de «sin dato» del DS, la misma que usa el resto del producto. */
+const UNKNOWN_VALUE = '—'
+
+const INTEGER = new Intl.NumberFormat('es-AR', { maximumFractionDigits: 0 })
+
+const DECIMAL = new Intl.NumberFormat('es-AR', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+})
+
+const MONEY = new Intl.NumberFormat('es-AR', {
+ style: 'currency',
+ currency: 'ARS',
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+})
+
+const ROUND_MONEY = new Intl.NumberFormat('es-AR', {
+ style: 'currency',
+ currency: 'ARS',
+ maximumFractionDigits: 0,
+})
+
+/**
+ * Entero con separador de miles: `4280` → `"4.280"`.
+ *
+ * Un valor que no es un número finito sale como «—» y no como `NaN`.
+ * TypeScript dice que no puede pasar, pero el dato viene de la API: si una
+ * versión más vieja no manda un campo que esta pantalla ya lee —un front
+ * desplegado antes que su backend—, `Intl` imprime `NaN` en la cara del
+ * operador. «—» dice lo mismo que diría cualquier otro dato que falta.
+ *
+ * La guarda estaba en una sola de las copias que este módulo reemplaza
+ * (`inventory/utils/format.ts`, de una review de TESIS-163). Al unificar, la
+ * heredan todas.
+ */
+export function formatInteger(value: number): string {
+ return Number.isFinite(value) ? INTEGER.format(value) : UNKNOWN_VALUE
+}
+
+/** Dos decimales siempre: `1.45` → `"1,45"`. Un peso, una medida. */
+export function formatDecimal(value: number): string {
+ return Number.isFinite(value) ? DECIMAL.format(value) : UNKNOWN_VALUE
+}
+
+/**
+ * Importe con centavos: `1478300.49` → `"$ 1.478.300,49"`.
+ *
+ * Con dos decimales siempre, aunque el diseño los omita: donde se usa 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 Number.isFinite(amount) ? MONEY.format(amount) : UNKNOWN_VALUE
+}
+
+/**
+ * Importe sin centavos: `58300` → `"$ 58.300"`.
+ *
+ * Para los costos que se leen de un vistazo en una tabla —el del envío— donde
+ * el centavo no cambia ninguna decisión y la columna gana en lectura.
+ */
+export function formatRoundMoney(amount: number): string {
+ return Number.isFinite(amount) ? ROUND_MONEY.format(amount) : UNKNOWN_VALUE
+}