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) { )} - - - - - {topNavContent.profileLabel} - - diff --git a/src/shared/components/TopNavBar/content.ts b/src/shared/components/TopNavBar/content.ts index 49ee99e..3da7eb2 100644 --- a/src/shared/components/TopNavBar/content.ts +++ b/src/shared/components/TopNavBar/content.ts @@ -8,12 +8,7 @@ export const topNavContent = { toggleThemeAriaLabel: 'Alternar tema', notificationsAriaLabel: 'Notificaciones', settingsAriaLabel: 'Configuración', - searchPlaceholder: 'Buscar órdenes...', - searchAriaLabel: 'Búsqueda global', - openSearchAriaLabel: 'Abrir búsqueda', - closeSearchAriaLabel: 'Cerrar búsqueda', userMenuAriaLabel: 'Cuenta de usuario', genericUserName: 'Usuario', - profileLabel: 'Mi perfil', logoutLabel: 'Cerrar sesión', } as const diff --git a/src/shared/components/index.ts b/src/shared/components/index.ts index 9d84220..b0aa585 100644 --- a/src/shared/components/index.ts +++ b/src/shared/components/index.ts @@ -7,6 +7,8 @@ export { StatusBadge } from './StatusBadge' export type { StatusVariant, StatusBadgeSize } from './StatusBadge' export { StatusSelect } from './StatusSelect' export type { StatusOption } from './StatusSelect' +export { ActivityPanel } from './ActivityPanel' +export type { ActivityPanelProps } from './ActivityPanel' export { TopNavBar } from './TopNavBar' export type { TopNavBarProps, TopNavUser, TopNavThemeMode } from './TopNavBar' export { CompactStatCard, StatCard } from './StatCard' diff --git a/src/shared/hooks/useActivity.ts b/src/shared/hooks/useActivity.ts new file mode 100644 index 0000000..c014405 --- /dev/null +++ b/src/shared/hooks/useActivity.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query' + +import { activityKeys, fetchActivity } from '../api/activity' +import type { ActivityEntry } from '../api/activity' + +/** + * La actividad reciente de la empresa, para el panel de la campanita + * (TESIS-163). + * + * `enabled` llega desde afuera: el panel se monta cerrado en todas las + * pantallas, y pedir el feed en cada visita para algo que nadie abrió sería un + * request por pantalla. Se pide al abrirlo y queda en caché. + */ +export function useActivity(enabled: boolean) { + return useQuery({ + queryKey: activityKeys.list(), + queryFn: fetchActivity, + enabled, + }) +} diff --git a/src/shared/store/orderDraftStore.ts b/src/shared/store/orderDraftStore.ts index f5e7530..97d1eca 100644 --- a/src/shared/store/orderDraftStore.ts +++ b/src/shared/store/orderDraftStore.ts @@ -65,9 +65,16 @@ interface OrderDraftState { origin: OrderDraftOrigin | null /** `null` hasta que el paso 2 se completa por primera vez. */ destination: OrderDraftDestination | null + /** + * Si la venta se despacha o la retira el cliente en el local (TESIS-162). Lo + * elige el paso 2. Arranca en `true`: el envío es el caso habitual, y hasta + * esta card era el único que el sistema sabía registrar. + */ + requiresShipping: boolean setCustomer: (customer: OrderDraftCustomer) => void setOrigin: (origin: OrderDraftOrigin | null) => void setDestination: (destination: OrderDraftDestination) => void + setRequiresShipping: (requiresShipping: boolean) => void /** Suma la línea. Si el producto ya estaba, la reemplaza: un SKU es una sola fila. */ addItem: (item: OrderDraftItem) => void updateItem: ( @@ -79,7 +86,13 @@ interface OrderDraftState { clearDraft: () => void } -const EMPTY_DRAFT = { customer: null, items: [], origin: null, destination: null } +const EMPTY_DRAFT = { + customer: null, + items: [], + origin: null, + destination: null, + requiresShipping: true, +} export const useOrderDraftStore = create()( persist( @@ -88,6 +101,7 @@ export const useOrderDraftStore = create()( setCustomer: (customer) => set({ customer }), setOrigin: (origin) => set({ origin }), setDestination: (destination) => set({ destination }), + setRequiresShipping: (requiresShipping) => set({ requiresShipping }), addItem: (item) => set((state) => { const index = state.items.findIndex((row) => row.productId === item.productId) From c7be77d36f5791359dda061ce7f2830b9895ac31 Mon Sep 17 00:00:00 2001 From: Tomas Martin Date: Sun, 4 Oct 2026 19:56:31 -0300 Subject: [PATCH 02/10] feat: [TESIS-163] add the shipments listing and act on the first round of feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things from trying it in the browser. **NaN.** The three stock figures printed `NaN` against an API that does not send them yet — a front deployed before its backend. `formatUnits` now answers with the em dash for anything that is not a finite number: TypeScript says it cannot happen, but the value comes from the API, and printing `NaN` at the operator is the one outcome nobody wants. **The failed events KPI is out.** "Unidades en stock" takes its place: the inventory the operation lives off, from the same data that already feeds the load per warehouse, so it costs no extra request. It does mean the panel no longer carries anything about the health of the integration nodes, which RF-26 names — that was the whole argument for the previous KPI, and the call was made twice, so it goes. **The activity rows got taller.** The panel is a history somebody reads, not a menu scanned with the eyes: each row now carries an icon tile for its kind, sits on 14px of vertical padding, separates with a divider, and the panel widened from 360 to 420. **The shipments listing exists.** `/shipments`, with tabs over the lifecycle and pagination on `GET /api/v1/shipments`, which has been there since TESIS-113 and only fed the panel KPI. There is no screen for a single shipment and that is not an oversight: its lifecycle, its log and its label live in the detail of its order, which is where the operator decides something, so every row leads there. Three cells say what is missing rather than drawing a blank, because the courier, the tracking and the cost are all written by the dispatch: a shipment with no quote does not cost zero, it has no price yet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp --- docs/guidelines/architecture.md | 17 +- src/app/router/routes.test.tsx | 9 +- src/app/router/routes.tsx | 9 + src/features/dashboard/api.ts | 13 -- src/features/dashboard/content.ts | 16 +- .../dashboard/hooks/useLogisticsKpis.ts | 29 +-- .../dashboard/pages/DashboardPage.test.tsx | 1 - .../dashboard/pages/DashboardPage.tsx | 33 ++-- src/features/dashboard/queryKeys.ts | 8 - src/features/inventory/utils/format.test.ts | 13 ++ src/features/inventory/utils/format.ts | 15 +- src/features/shipments/api.ts | 85 +++++++++ .../ShipmentsTable/ShipmentsTable.tsx | 165 ++++++++++++++++++ .../ShipmentsTable/ShipmentsTable.types.ts | 13 ++ .../components/ShipmentsTable/index.ts | 2 + src/features/shipments/content.ts | 71 ++++++++ src/features/shipments/hooks/useShipments.ts | 57 ++++++ src/features/shipments/index.ts | 1 + .../shipments/pages/ShipmentsPage.test.tsx | 154 ++++++++++++++++ .../shipments/pages/ShipmentsPage.tsx | 113 ++++++++++++ src/features/shipments/queryKeys.ts | 17 ++ src/features/shipments/types.ts | 46 +++++ src/features/shipments/utils/format.ts | 19 ++ .../ActivityPanel/ActivityPanel.styles.ts | 17 ++ .../ActivityPanel/ActivityPanel.tsx | 40 ++++- 25 files changed, 868 insertions(+), 95 deletions(-) create mode 100644 src/features/shipments/api.ts create mode 100644 src/features/shipments/components/ShipmentsTable/ShipmentsTable.tsx create mode 100644 src/features/shipments/components/ShipmentsTable/ShipmentsTable.types.ts create mode 100644 src/features/shipments/components/ShipmentsTable/index.ts create mode 100644 src/features/shipments/content.ts create mode 100644 src/features/shipments/hooks/useShipments.ts create mode 100644 src/features/shipments/index.ts create mode 100644 src/features/shipments/pages/ShipmentsPage.test.tsx create mode 100644 src/features/shipments/pages/ShipmentsPage.tsx create mode 100644 src/features/shipments/queryKeys.ts create mode 100644 src/features/shipments/types.ts create mode 100644 src/features/shipments/utils/format.ts create mode 100644 src/shared/components/ActivityPanel/ActivityPanel.styles.ts diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 5f881d4..336c5cb 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -308,14 +308,15 @@ features/[nombre]/ **Features existentes:** -| 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`) | +| Feature | Contenido | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `dashboard` | Panel de operación (`/dashboard`, S03). KPIs de órdenes pendientes, envíos activos, **unidades en stock** y alertas de inventario; abajo, las órdenes recientes y la columna con los **últimos envíos** y la carga por depósito. En TESIS-163 se fueron el KPI de «Salud del sistema» y la tarjeta de Integraciones —con ellos `useInfraHealth`, `IntegrationNodeList` y `NodeStatusIcon`—, y los reemplazos salen de datos que la pantalla ya pedía: las unidades guardadas que alimentan la carga por depósito, y `/shipments`. La barra de carga mide **ocupación real** en los depósitos que declararon capacidad (TESIS-162) y sigue comparando entre sí los que no | +| `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks | +| `home` | Landing de la app (`/`) | +| `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` | +| `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`) | +| `shipments` | Listado de envíos (`/shipments`, TESIS-163): pestañas por estado del ciclo, paginado sobre `GET /api/v1/shipments`, que existía desde TESIS-113 y sólo alimentaba el KPI del panel. No hay pantalla de detalle del envío y no es un olvido: el ciclo de vida, la bitácora y la etiqueta viven en el detalle de su orden (S08), así que cada fila lleva ahí | **`orders` — piezas y por qué:** diff --git a/src/app/router/routes.test.tsx b/src/app/router/routes.test.tsx index 7438c93..8e37227 100644 --- a/src/app/router/routes.test.tsx +++ b/src/app/router/routes.test.tsx @@ -24,7 +24,14 @@ describe('navRoutesFor', () => { }) it('keeps the sections that belong to the product for every tenant', () => { - expect(navPaths({})).toEqual(['/', '/dashboard', '/orders', '/inventory', '/reports']) + expect(navPaths({})).toEqual([ + '/', + '/dashboard', + '/orders', + '/shipments', + '/inventory', + '/reports', + ]) }) it('never lists a route that has no place in the sidebar', () => { diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx index 1fa6f74..2182f86 100644 --- a/src/app/router/routes.tsx +++ b/src/app/router/routes.tsx @@ -3,6 +3,7 @@ import HomeIcon from '@mui/icons-material/Home' import HubOutlinedIcon from '@mui/icons-material/HubOutlined' import InsightsOutlinedIcon from '@mui/icons-material/InsightsOutlined' import Inventory2Icon from '@mui/icons-material/Inventory2' +import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined' import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined' import { lazy } from 'react' import type { ReactNode } from 'react' @@ -42,6 +43,9 @@ const InventoryPage = lazy(() => const ProductDetailPage = lazy(() => import('features/inventory').then((m) => ({ default: m.ProductDetailPage })), ) +const ShipmentsPage = lazy(() => + import('features/shipments').then((m) => ({ default: m.ShipmentsPage })), +) const ReportsPage = lazy(() => import('features/reports').then((m) => ({ default: m.ReportsPage }))) const LoginPage = lazy(() => import('features/auth').then((m) => ({ default: m.LoginPage }))) @@ -132,6 +136,11 @@ export const appRoutes: AppRoute[] = [ // encendida y Sur no (§2 del contrato). feature: 'integrations', }, + { + path: '/shipments', + element: , + nav: { label: 'Envíos', icon: }, + }, { path: '/inventory', element: , diff --git a/src/features/dashboard/api.ts b/src/features/dashboard/api.ts index 6c40758..52d1ecf 100644 --- a/src/features/dashboard/api.ts +++ b/src/features/dashboard/api.ts @@ -98,19 +98,6 @@ export async function fetchWarehouseLoads(): Promise { })) } -/** - * 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 diff --git a/src/features/dashboard/content.ts b/src/features/dashboard/content.ts index fc5b463..6e11626 100644 --- a/src/features/dashboard/content.ts +++ b/src/features/dashboard/content.ts @@ -24,14 +24,14 @@ export const dashboardCopy = { }, // 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', + // Reemplaza al KPI de «Salud del sistema» (TESIS-163), que mostraba un + // porcentaje derivado de la frescura de los nodos. Las unidades guardadas + // son el inventario del que vive la operación, y salen del mismo dato que + // ya alimenta la carga por depósito: ni un request más. + storedUnits: { + label: 'Unidades en stock', + note: (warehouses: number) => + warehouses === 1 ? 'en 1 depósito' : `repartidas en ${warehouses} depósitos`, }, // Cuarta tarjeta de la fila del diseño. El chip y la nota son los del // MetricCard de S03-Panel; el tono `error` es lo que le da el borde de diff --git a/src/features/dashboard/hooks/useLogisticsKpis.ts b/src/features/dashboard/hooks/useLogisticsKpis.ts index c5aaf4b..47ebc44 100644 --- a/src/features/dashboard/hooks/useLogisticsKpis.ts +++ b/src/features/dashboard/hooks/useLogisticsKpis.ts @@ -4,12 +4,10 @@ import { useCallback } from 'react' import { ACTIVE_SHIPMENT_STATUS, fetchActiveShipmentCount, - fetchPendingFailedEventCount, fetchPendingOrderCount, - PENDING_FAILED_EVENT_STATUS, PENDING_ORDER_STATUS, } from '../api' -import { failedEventKeys, orderKeys, shipmentKeys } from '../queryKeys' +import { orderKeys, shipmentKeys } from '../queryKeys' export interface KpiState { /** El total que devolvió la API, o `undefined` mientras carga o si falló. */ @@ -21,14 +19,7 @@ export interface KpiState { export interface LogisticsKpis { pendingOrders: KpiState activeShipments: KpiState - /** - * 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ó. */ + /** Alguna de las dos consultas falló. */ isError: boolean refetch: () => void } @@ -54,22 +45,15 @@ 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() - void refetchFailedEvents() - }, [refetchPendingOrders, refetchActiveShipments, refetchFailedEvents]) + }, [refetchPendingOrders, refetchActiveShipments]) return { pendingOrders: { @@ -82,12 +66,7 @@ export function useLogisticsKpis(): LogisticsKpis { isLoading: activeShipments.isLoading, isError: activeShipments.isError, }, - failedEvents: { - value: failedEvents.data, - isLoading: failedEvents.isLoading, - isError: failedEvents.isError, - }, - isError: pendingOrders.isError || activeShipments.isError || failedEvents.isError, + isError: pendingOrders.isError || activeShipments.isError, refetch, } } diff --git a/src/features/dashboard/pages/DashboardPage.test.tsx b/src/features/dashboard/pages/DashboardPage.test.tsx index d4d1353..91c978d 100644 --- a/src/features/dashboard/pages/DashboardPage.test.tsx +++ b/src/features/dashboard/pages/DashboardPage.test.tsx @@ -98,7 +98,6 @@ 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(), }) diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx index 98b310a..eff3662 100644 --- a/src/features/dashboard/pages/DashboardPage.tsx +++ b/src/features/dashboard/pages/DashboardPage.tsx @@ -1,5 +1,5 @@ +import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined' import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined' -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' @@ -16,7 +16,7 @@ import { useRecentOrders } from '../hooks/useRecentOrders' import { useRecentShipments } from '../hooks/useRecentShipments' const { metrics, error: errorCopy } = dashboardCopy -const failedEventsCopy = metrics.failedEvents +const storedUnitsCopy = metrics.storedUnits const alertsCopy = metrics.inventoryAlerts // Destino del click en la tarjeta de alertas. Las rutas se registran en @@ -70,7 +70,6 @@ export function DashboardPage() { const { pendingOrders, activeShipments, - failedEvents, isError: isKpisError, refetch: refetchKpis, } = useLogisticsKpis() @@ -115,11 +114,6 @@ export function DashboardPage() { const hasAlerts = alerts.value !== undefined && alerts.value > 0 const alertsValue = formatCount(alerts.value) - // 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 ( @@ -164,22 +158,15 @@ 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. */} + {/* 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} - - )} -