diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 991dc8f..99a49a5 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -241,7 +241,7 @@ interface PaginationParams { | `ProgressIndicator` | Ver `ProgressIndicator.types.ts` | Barra lineal con tono semántico. `size` thin/medium/large, `layout` stacked/inline, `indeterminate` para progreso desconocido. El ancho sale del porcentaje | | `StepsProgress` | `total`, `completed`, `tone?`, `label?`, `caption?` | Progreso por etapas discretas, para procesos con pasos nombrados | | `ProgressSkeleton` | `label?`, `avatar?`, `lines?` | Placeholder de carga con la silueta del contenido que reemplaza | -| `TopNavBar` | Ver `TopNavBar.types.ts` | Shell de navegación global (brand + búsqueda + acciones + usuario). Presentacional — sin datos, sin `uiStore`; único acople: el `Link` de Router (brand/engranaje). Fixed/z-index intrínsecos vía `MuiAppBar` en el tema. Cableado real en `app/layout/Header.tsx`. | +| `TopNavBar` | Ver `TopNavBar.types.ts` | Shell de navegación global (brand + acciones + usuario). El buscador se sacó en TESIS-163: no tenía `onSearch`, era un input decorativo. La campanita entrega el botón que la disparó, para que quien la monta ancle su panel ahí, y lo que cuelgue va como `children`. Presentacional — sin datos, sin `uiStore`; único acople: el `Link` de Router (brand/engranaje). Fixed/z-index intrínsecos vía `MuiAppBar` en el tema. Cableado real en `app/layout/Header.tsx`. | | `StatusFeed` | Ver `StatusFeed.types.ts` | Bitácora de eventos de alta densidad: barra fina como eje temporal, título y metadato en monoespaciada. `current` marca la entrada vigente; el resto se atenúa. Renderiza `
    ` porque el orden es información | | `Logo` | `brand`, `tagline` | Lockup de marca (isotipo + wordmark). Las reglas del manual viajan con el componente: área de respeto como padding propio y mínimo de 140px como `minWidth` | | `LogoMark` | `size?: number` | Isotipo suelto, inline y con `currentColor` para que herede el color del contenedor | @@ -308,15 +308,17 @@ features/[nombre]/ **Features existentes:** -| Feature | Contenido | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dashboard` | Panel de operación y pantalla de entrada de la app (`/`, S03). Salud de los nodos de integración (`useInfraHealth`, `IntegrationNodeList`), alertas de inventario, carga por depósito y órdenes recientes (`useInventoryAlerts`, `WarehouseLoadCard`, `useRecentOrders`, `RecentOrdersTable`) | -| `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks | -| `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` | -| `warehouses` | Depósitos de la empresa (`/warehouses`, RF-03): listado, alta, edición y baja sobre `/api/v1/warehouses`. Sin maqueta: tabla, modal y confirmación del DS. Sus mutaciones invalidan todas las queries, porque los depósitos llenan selects de otras features cuyas claves esta no puede importar | -| `failed-events` | Cola de eventos fallidos (`/failed-events`, RF-16): lo que el motor de reintentos todavía intenta, lo agotado y lo resuelto, con reintento y descarte manuales. Las acciones de cada fila salen de `utils/actions.ts`, espejo de los estados que la API acepta | -| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío, desde el que se despacha un envío que quedó pendiente (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los tres pasos del alta manual (`/orders/new`: cliente y productos, con el buscador filtrando contra el `search` del backend; `/orders/new/shipping`: origen y destino; `/orders/new/carrier`: cotización y confirmación) | -| `reports` | Reportes — analítica de la operación (`/reports`, S14) sobre `GET /reports/overview`: facturación, unidades despachadas, curva por día o semana y entregas por operador. Cumplimiento de plazo y anomalías llegan en `null` (el modelo no los tiene) y se muestran sin dato, diciendo por qué | +| Feature | Contenido | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dashboard` | Panel de operación (`/dashboard`, S03). KPIs de órdenes pendientes, envíos activos, **unidades en stock** y alertas de inventario; abajo, las órdenes recientes y la columna con los **últimos envíos** y la carga por depósito. En TESIS-163 se fueron el KPI de «Salud del sistema» y la tarjeta de Integraciones —con ellos `useInfraHealth`, `IntegrationNodeList` y `NodeStatusIcon`—, y los reemplazos salen de datos que la pantalla ya pedía: las unidades guardadas que alimentan la carga por depósito, y `/shipments`. La barra de carga mide **ocupación real** en los depósitos que declararon capacidad (TESIS-162) y sigue comparando entre sí los que no | +| `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks | +| `home` | Landing de la app (`/`) | +| `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` | +| `warehouses` | Depósitos de la empresa (`/warehouses`, RF-03): listado, alta, edición y baja sobre `/api/v1/warehouses`. Sin maqueta: tabla, modal y confirmación del DS. Sus mutaciones invalidan todas las queries, porque los depósitos llenan selects de otras features cuyas claves esta no puede importar | +| `failed-events` | Cola de eventos fallidos (`/failed-events`, RF-16): lo que el motor de reintentos todavía intenta, lo agotado y lo resuelto, con reintento y descarte manuales. Las acciones de cada fila salen de `utils/actions.ts`, espejo de los estados que la API acepta | +| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío, desde el que se despacha un envío que quedó pendiente (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los tres pasos del alta manual (`/orders/new`: cliente y productos, con el buscador filtrando contra el `search` del backend; `/orders/new/shipping`: origen, **cómo la recibe el cliente** y destino; `/orders/new/carrier`: cotización y confirmación). Con **retiro en el local** (TESIS-162) el paso 3 no cotiza ni despacha, y el detalle y el listado lo dicen: una orden que no lleva envío deja de verse igual que una a la que le falta | +| `reports` | Reportes — analítica de la operación (`/reports`, S14) sobre `GET /reports/overview`: facturación, unidades despachadas, curva por día o semana y entregas por operador. Cumplimiento de plazo y anomalías llegan en `null` (el modelo no los tiene) y se muestran sin dato, diciendo por qué | +| `shipments` | Listado de envíos (`/shipments`, TESIS-163): pestañas por estado del ciclo, paginado sobre `GET /api/v1/shipments`, que existía desde TESIS-113 y sólo alimentaba el KPI del panel. No hay pantalla de detalle del envío y no es un olvido: el ciclo de vida, la bitácora y la etiqueta viven en el detalle de su orden (S08), así que cada fila lleva ahí | **`orders` — piezas y por qué:** diff --git a/src/app/layout/Header.tsx b/src/app/layout/Header.tsx index bdcacb5..66f4627 100644 --- a/src/app/layout/Header.tsx +++ b/src/app/layout/Header.tsx @@ -1,8 +1,14 @@ -import { TopNavBar } from 'shared/components' +import { useState } from 'react' +import { ActivityPanel, TopNavBar } from 'shared/components' import type { TopNavUser } from 'shared/components' import { useAuthStore, useTenantName, useUiStore } from 'shared/store' import { useThemeMode } from 'shared/store/uiStore' +// Las rutas de destino del panel se declaran acá y no se importan del router: +// `shared/components` no puede depender de `app/`. El de eventos fallidos +// todavía no existe (TESIS-147); sin él la fila se muestra y no enlaza. +const ACTIVITY_PATHS = { order: (id: number) => `/orders/${id}` } + export function Header() { // Selectores individuales (no el store completo): Header está en todas las // pantallas vía TopNavBar, así que solo re-renderiza cuando cambia una de @@ -13,6 +19,8 @@ export function Header() { const sessionUser = useAuthStore((state) => state.user) const logout = useAuthStore((state) => state.logout) const organization = useTenantName() + // El botón que abrió el panel: el Popover se ancla a él, y `null` lo cierra. + const [bell, setBell] = useState(null) // `sessionUser` es null hasta que `GET /me` contesta, también al recargar la // página con sesión abierta. El menú de cuenta cae a su nombre genérico @@ -30,6 +38,9 @@ export function Header() { onToggleTheme={toggleTheme} user={user} onLogout={logout} - /> + onNotificationsClick={setBell} + > + setBell(null)} paths={ACTIVITY_PATHS} /> + ) } diff --git a/src/app/router/routes.test.tsx b/src/app/router/routes.test.tsx index 93c7b2b..00f8885 100644 --- a/src/app/router/routes.test.tsx +++ b/src/app/router/routes.test.tsx @@ -15,6 +15,7 @@ describe('navRoutes', () => { expect(paths).toEqual([ '/', '/orders', + '/shipments', '/inventory', '/warehouses', '/failed-events', diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx index 6223c5e..79531b7 100644 --- a/src/app/router/routes.tsx +++ b/src/app/router/routes.tsx @@ -2,6 +2,7 @@ import BarChartOutlinedIcon from '@mui/icons-material/BarChartOutlined' import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' import InsightsOutlinedIcon from '@mui/icons-material/InsightsOutlined' import Inventory2Icon from '@mui/icons-material/Inventory2' +import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined' import ReceiptLongOutlinedIcon from '@mui/icons-material/ReceiptLongOutlined' import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined' import { lazy } from 'react' @@ -36,6 +37,9 @@ const InventoryPage = lazy(() => const ProductDetailPage = lazy(() => import('features/inventory').then((m) => ({ default: m.ProductDetailPage })), ) +const ShipmentsPage = lazy(() => + import('features/shipments').then((m) => ({ default: m.ShipmentsPage })), +) const WarehousesPage = lazy(() => import('features/warehouses').then((m) => ({ default: m.WarehousesPage })), ) @@ -117,6 +121,11 @@ export const appRoutes: AppRoute[] = [ element: , // Sin `nav`: se llega desde el listado, no desde el Sidebar. }, + { + path: '/shipments', + element: , + nav: { label: 'Envíos', icon: }, + }, { path: '/inventory', element: , diff --git a/src/features/dashboard/api.test.ts b/src/features/dashboard/api.test.ts new file mode 100644 index 0000000..d221486 --- /dev/null +++ b/src/features/dashboard/api.test.ts @@ -0,0 +1,93 @@ +import type { AxiosResponse } from 'axios' +import { client } from 'shared/api/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { fetchRecentShipments, fetchWarehouseLoads, RECENT_SHIPMENTS } from './api' + +function respond(data: unknown): AxiosResponse { + return { data, headers: {} } as AxiosResponse +} + +/** Los query params con los que se llamó al endpoint. */ +function capture(body: unknown) { + const sent: Record[] = [] + vi.spyOn(client, 'get').mockImplementation((_url: string, config?: unknown) => { + sent.push((config as { params?: Record })?.params ?? {}) + + return Promise.resolve(respond(body)) + }) + + return sent +} + +const SHIPMENT = { + id: 31, + order_id: 8829, + status: 'in_transit', + tracking_number: 'AND-9920-X8829-Z', + courier: { id: 4, service_id: 7, name: 'Andreani' }, + created_at: '2026-08-24T12:14:00Z', +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('fetchRecentShipments', () => { + it('asks for one page of the size the panel shows', async () => { + const sent = capture({ data: [] }) + await fetchRecentShipments() + + expect(sent[0]).toMatchObject({ page: 1, per_page: RECENT_SHIPMENTS }) + }) + + it('translates the shipment to the domain', async () => { + capture({ data: [SHIPMENT] }) + + expect(await fetchRecentShipments()).toEqual([ + { + id: 31, + orderId: 8829, + status: 'in_transit', + trackingNumber: 'AND-9920-X8829-Z', + courier: 'Andreani', + createdAt: '2026-08-24T12:14:00Z', + }, + ]) + }) + + // El operador se asigna al confirmar el despacho (TESIS-47): hasta entonces + // la fila tiene que tolerar que no haya ninguno. + it('leaves the courier null for a shipment that has none', async () => { + capture({ data: [{ ...SHIPMENT, courier: null, tracking_number: null }] }) + const [shipment] = await fetchRecentShipments() + + expect(shipment).toMatchObject({ courier: null, trackingNumber: null }) + }) +}) + +describe('fetchWarehouseLoads', () => { + it('translates each warehouse to the domain', async () => { + capture({ data: [{ id: 1, name: 'CD Norte', stored_units: 200, capacity: 1000 }] }) + + expect(await fetchWarehouseLoads()).toEqual([ + { id: 1, name: 'CD Norte', storedUnits: 200, capacity: 1000 }, + ]) + }) + + // Si el front se despliega antes que el backend que agrega el campo, + // `undefined` pasa el `!== null` de `shareOf` y la barra sale en «NaN %». + it('reads a missing capacity as one that was never declared', async () => { + capture({ data: [{ id: 1, name: 'CD Norte', stored_units: 200 }] }) + const [warehouse] = await fetchWarehouseLoads() + + expect(warehouse.capacity).toBeNull() + }) + + it('reads missing stored units as zero instead of NaN', async () => { + capture({ data: [{ id: 1, name: 'CD Norte', capacity: 1000 }] }) + const [warehouse] = await fetchWarehouseLoads() + + expect(warehouse.storedUnits).toBe(0) + }) +}) diff --git a/src/features/dashboard/api.ts b/src/features/dashboard/api.ts index 4dda81f..18b6edf 100644 --- a/src/features/dashboard/api.ts +++ b/src/features/dashboard/api.ts @@ -1,7 +1,7 @@ import { client } from 'shared/api/client' import { fetchCount } from 'shared/api/count' -import type { RecentOrder, WarehouseLoad } from './types' +import type { DispatchedShipment, RecentOrder, WarehouseLoad } from './types' // Frontera con Rails de los KPIs de órdenes y envíos (TESIS-53). Único lugar de // la feature que conoce los endpoints y el vocabulario de estados del backend. @@ -77,6 +77,7 @@ interface ApiWarehouse { id: number name: string stored_units: number + capacity: number | null } /** @@ -92,7 +93,45 @@ export async function fetchWarehouseLoads(): Promise { return data.data.map((warehouse) => ({ id: warehouse.id, name: warehouse.name, - storedUnits: warehouse.stored_units, + // `?? null` y no el valor crudo: si el front se despliega antes que el + // backend que agrega el campo, `undefined` pasa el `!== null` de + // `shareOf` y la barra sale en NaN %. + storedUnits: warehouse.stored_units ?? 0, + capacity: warehouse.capacity ?? null, + })) +} + +/** Cuántos envíos muestra la tarjeta del panel. */ +export const RECENT_SHIPMENTS = 5 + +interface ApiDispatchedShipment { + id: number + order_id: number + status: DispatchedShipment['status'] + tracking_number: string | null + courier: { id: number; service_id: number; name: string } | null + created_at: string +} + +/** + * Los últimos envíos de la empresa (`GET /api/v1/shipments?page=1&per_page=5`). + * + * Igual que las órdenes recientes: el backend los devuelve del más nuevo al más + * viejo, así que la primera página *es* «los últimos cinco» y no se reordena de + * este lado. + */ +export async function fetchRecentShipments(): Promise { + const { data } = await client.get<{ data: ApiDispatchedShipment[] }>('/shipments', { + params: { page: 1, per_page: RECENT_SHIPMENTS }, + }) + + return data.data.map((shipment) => ({ + id: shipment.id, + orderId: shipment.order_id, + status: shipment.status, + trackingNumber: shipment.tracking_number, + courier: shipment.courier?.name ?? null, + createdAt: shipment.created_at, })) } diff --git a/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.styles.ts b/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.styles.ts deleted file mode 100644 index 288961e..0000000 --- a/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.styles.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Box, Card, Typography } from '@mui/material' -import { styled } from '@mui/material/styles' - -// Geometría de la fila, tomada de `S03-Panel`: tile de 32px con radio 8 y un -// ícono de 18px adentro. -const SERVICE_TILE = { size: 32, iconSize: 18 } - -// Filas fantasma del estado de carga. Ids fijos y no índices: `react/no-array-index-key`. -export const SKELETON_ROWS = ['first', 'second', 'third'] - -export const SKELETON_TILE = SERVICE_TILE.size - -// El Card del tema ya trae borde/sombra/radio — acá solo layout. -export const ListCard = styled(Card)(({ theme }) => ({ - padding: theme.spacing(2), - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(2), - height: '100%', -})) - -// El ícono `hub` del diseño va al extremo derecho de la cabecera. -export const HeaderRow = styled(Box)(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1), - '& > svg': { marginLeft: 'auto', color: theme.vars.palette.text.secondary, display: 'block' }, -})) - -export const NodeRows = styled(Box)(({ theme }) => ({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(2), -})) - -export const NodeRow = styled(Box)(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1.5), -})) - -// Tile de identidad del servicio. Superficie tonal del tema, no un color crudo. -export const ServiceTile = styled(Box)(({ theme }) => ({ - width: SERVICE_TILE.size, - height: SERVICE_TILE.size, - flexShrink: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - borderRadius: theme.shape.borderRadius, - backgroundColor: theme.vars.palette.background.containerHighest, - color: theme.vars.palette.text.secondary, - '& svg': { fontSize: SERVICE_TILE.iconSize, display: 'block' }, -})) - -// `minWidth: 0` para que el nombre largo se recorte en vez de empujar el ícono -// de estado fuera de la tarjeta. -export const NodeIdentity = styled(Box)({ - display: 'flex', - flexDirection: 'column', - flexGrow: 1, - minWidth: 0, -}) - -// El diseño pide el nombre en 14px semibold: el tamaño sale de `bodyMd` y el -// peso de `labelMd`, así ningún número queda escrito acá. -export const NodeName = styled(Typography)(({ theme }) => ({ - fontWeight: theme.typography.labelMd.fontWeight, - // El nombre sí se recorta: es reconocible por el prefijo y no puede robarle - // alto a la fila. - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', -})) - -interface SyncLineProps { - degraded: boolean -} - -const TRANSIENT_PROPS = new Set(['degraded']) - -// Línea de sincronización: tamaño de `labelSm` con la familia monoespaciada de -// `dataMono`, como en el diseño. En rojo cuando la sync quedó atrasada. -export const SyncLine = styled(Typography, { - shouldForwardProp: (prop) => !TRANSIENT_PROPS.has(prop as string), -})(({ theme, degraded }) => ({ - fontFamily: theme.typography.dataMono.fontFamily, - color: degraded ? theme.vars.palette.error.main : theme.vars.palette.text.secondary, - // A diferencia del nombre, esta línea NO se recorta: el tiempo transcurrido - // vive al final de la frase, y truncarlo sería perder justamente el dato que - // la fila existe para mostrar. En una columna angosta envuelve a dos renglones. -})) diff --git a/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.tsx b/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.tsx deleted file mode 100644 index b0e59b1..0000000 --- a/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import HubOutlinedIcon from '@mui/icons-material/HubOutlined' -import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined' -import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined' -import { Skeleton, Typography } from '@mui/material' -import type { ReactNode } from 'react' -import type { ServiceType } from 'shared/api' -import { formatRelativeTime } from 'shared/utils' - -import { dashboardCopy } from '../../content' -import type { InfraNode } from '../../hooks/useInfraHealth' -import { NodeStatusIcon } from '../NodeStatusIcon' - -import { - HeaderRow, - ListCard, - NodeIdentity, - NodeName, - NodeRow, - NodeRows, - SKELETON_ROWS, - SKELETON_TILE, - ServiceTile, - SyncLine, -} from './IntegrationNodeList.styles' -import type { IntegrationNodeListProps } from './IntegrationNodeList.types' - -const nodesCopy = dashboardCopy.infra.nodes - -// El diseño distingue los nodos por el ícono del tile. Acá el criterio es el -// tipo de servicio, que es el único eje que expone la API. -const SERVICE_ICON: Record = { - ecommerce: , - courier: , -} - -// Frase de estado de la fila. Sin marca de sync no se inventa un tiempo: se -// dice que el dato no está. -function syncLabel(node: InfraNode): string { - if (!node.lastSyncedAt) return nodesCopy.sync.unknown - - const elapsed = formatRelativeTime(node.lastSyncedAt) - if (elapsed === null) return nodesCopy.sync.unknown - - return node.status === 'stale' ? nodesCopy.sync.stale(elapsed) : nodesCopy.sync.online(elapsed) -} - -// Widget "Integraciones" de `S03-Panel`: por cada nodo activo, su identidad, el -// tiempo desde la última sincronización exitosa y el estado resultante. -export function IntegrationNodeList({ - nodes, - reportingNodes, - onlineNodes, - loading = false, -}: IntegrationNodeListProps) { - // Mientras ningún nodo reporte sync, el contador "x/y sincronizados" no - // significa nada: se muestra cuántas integraciones activas hay y se aclara - // que la marca de sync no está llegando. - const subtitle = - reportingNodes === 0 - ? nodesCopy.subtitleNoReports(nodes.length) - : nodesCopy.subtitleSynced(onlineNodes, reportingNodes) - - const rows = loading - ? SKELETON_ROWS.map((id) => ( - - - - - )) - : nodes.map((node) => ( - - - {SERVICE_ICON[node.type]} - - - {node.name} - - {syncLabel(node)} - - - - - )) - - return ( - - - {nodesCopy.title} - - - - - {loading ? '' : subtitle} - - - {!loading && nodes.length === 0 ? ( - - {nodesCopy.empty} - - ) : ( - {rows} - )} - - ) -} diff --git a/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.types.ts b/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.types.ts deleted file mode 100644 index 812ab81..0000000 --- a/src/features/dashboard/components/IntegrationNodeList/IntegrationNodeList.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { InfraNode } from '../../hooks/useInfraHealth' - -export interface IntegrationNodeListProps { - /** Nodos ya filtrados (configurados y activos) y derivados por `useInfraHealth`. */ - nodes: InfraNode[] - /** Nodos que traen marca de sync — denominador del contador de la cabecera. */ - reportingNodes: number - onlineNodes: number - /** Muestra filas fantasma mientras llega la respuesta. */ - loading?: boolean -} diff --git a/src/features/dashboard/components/IntegrationNodeList/index.ts b/src/features/dashboard/components/IntegrationNodeList/index.ts deleted file mode 100644 index f869d00..0000000 --- a/src/features/dashboard/components/IntegrationNodeList/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { IntegrationNodeList } from './IntegrationNodeList' -export type { IntegrationNodeListProps } from './IntegrationNodeList.types' diff --git a/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.styles.ts b/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.styles.ts deleted file mode 100644 index 8a69fdd..0000000 --- a/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.styles.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Box } from '@mui/material' -import { styled } from '@mui/material/styles' -import type { StatusVariant } from 'shared/components' - -import type { NodeSyncStatus } from '../../types' - -// Tono por estado. `S03-Panel` pinta el nodo sano con `--ok` y el caído con -// `--err`; el diseño no contempla un tercer estado porque asume que todo nodo -// reporta, así que `unknown` va en neutro — apagado, no alarmado. -export const STATUS_TONE: Record = { - online: 'success', - stale: 'error', - unknown: 'neutral', -} - -// 16px es el tamaño del ícono de estado en el diseño. -const ICON_SIZE = 16 - -interface StatusRootProps { - tone: StatusVariant -} - -const TRANSIENT_PROPS = new Set(['tone']) - -export const StatusRoot = styled(Box, { - shouldForwardProp: (prop) => !TRANSIENT_PROPS.has(prop as string), -})(({ theme, tone }) => ({ - display: 'inline-flex', - flexShrink: 0, - color: theme.vars.palette[tone].main, - '& svg': { display: 'block', fontSize: ICON_SIZE }, -})) diff --git a/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.tsx b/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.tsx deleted file mode 100644 index 72ba6b8..0000000 --- a/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import CheckCircleIcon from '@mui/icons-material/CheckCircle' -import ErrorIcon from '@mui/icons-material/Error' -import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline' -import type { ReactNode } from 'react' - -import type { NodeSyncStatus } from '../../types' - -import { STATUS_TONE, StatusRoot } from './NodeStatusIcon.styles' -import type { NodeStatusIconProps } from './NodeStatusIcon.types' - -// Los dos primeros son los del diseño (`check_circle` / `error`). El tercero -// cubre el nodo que no reporta: un círculo tachado lee como "sin señal" y no -// como "falla". -const STATUS_ICON: Record = { - online: , - stale: , - unknown: , -} - -// Indicador de estado del nodo, en la columna derecha de la fila. -export function NodeStatusIcon({ status, label }: NodeStatusIconProps) { - return ( - - {STATUS_ICON[status]} - - ) -} diff --git a/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.types.ts b/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.types.ts deleted file mode 100644 index 8c5c17d..0000000 --- a/src/features/dashboard/components/NodeStatusIcon/NodeStatusIcon.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { NodeSyncStatus } from '../../types' - -export interface NodeStatusIconProps { - /** Estado de frescura del nodo, ya derivado por `useInfraHealth`. */ - status: NodeSyncStatus - /** - * Texto del estado. Obligatorio a propósito: el ícono nunca comunica solo con - * color, y además es lo único que lee un lector de pantalla en esta columna. - */ - label: string -} diff --git a/src/features/dashboard/components/NodeStatusIcon/index.ts b/src/features/dashboard/components/NodeStatusIcon/index.ts deleted file mode 100644 index e2a9f31..0000000 --- a/src/features/dashboard/components/NodeStatusIcon/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { NodeStatusIcon } from './NodeStatusIcon' -export type { NodeStatusIconProps } from './NodeStatusIcon.types' diff --git a/src/features/dashboard/components/RecentOrdersTable/RecentOrdersTable.tsx b/src/features/dashboard/components/RecentOrdersTable/RecentOrdersTable.tsx index 4fbd1a6..a35dc4b 100644 --- a/src/features/dashboard/components/RecentOrdersTable/RecentOrdersTable.tsx +++ b/src/features/dashboard/components/RecentOrdersTable/RecentOrdersTable.tsx @@ -3,11 +3,11 @@ import { Button, Link, Typography } from '@mui/material' import { Link as RouterLink } from 'react-router-dom' import { DataTable, StackedCell, StatusBadge } from 'shared/components' import type { DataTableColumn } from 'shared/components' +import { formatMoney } from 'shared/utils' import { dashboardCopy } from '../../content' import type { RecentOrder } from '../../types' import { - formatMoney, formatOrderId, formatShortDate, formatTime, diff --git a/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.styles.ts b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.styles.ts new file mode 100644 index 0000000..8ab4369 --- /dev/null +++ b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.styles.ts @@ -0,0 +1,93 @@ +import { Box, Card, Typography } from '@mui/material' +import { styled } from '@mui/material/styles' +import { Link as RouterLink } from 'react-router-dom' + +// Misma geometría que tenía la tarjeta de Integraciones, a la que reemplaza +// (TESIS-163): la columna de la derecha del panel no cambia de forma. +const SERVICE_TILE = { size: 32, iconSize: 18 } + +// Filas fantasma del estado de carga. Ids fijos y no índices: `react/no-array-index-key`. +export const SKELETON_ROWS = ['first', 'second', 'third'] + +export const SKELETON_TILE = SERVICE_TILE.size + +// El Card del tema ya trae borde/sombra/radio — acá solo layout. +export const ListCard = styled(Card)(({ theme }) => ({ + padding: theme.spacing(2), + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + height: '100%', +})) + +export const HeaderRow = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + '& > svg': { marginLeft: 'auto', color: theme.vars.palette.text.secondary, display: 'block' }, +})) + +export const ShipmentRows = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), +})) + +export const ShipmentRow = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1.5), +})) + +// La fila entera lleva a la orden, que es donde vive el detalle del envío. Es +// un `Link` envolviendo y no un `component` en la fila: así el skeleton usa la +// misma fila sin arrastrar un destino que todavía no existe. +export const RowLink = styled(RouterLink)(({ theme }) => ({ + textDecoration: 'none', + color: 'inherit', + borderRadius: theme.shape.borderRadius, + '&:hover': { backgroundColor: theme.alpha(theme.vars.palette.text.primary, 0.04) }, + '&:focus-visible': { + outline: `2px solid ${theme.vars.palette.primary.main}`, + outlineOffset: 2, + }, +})) + +export const ShipmentTile = styled(Box)(({ theme }) => ({ + width: SERVICE_TILE.size, + height: SERVICE_TILE.size, + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.vars.palette.background.containerHighest, + color: theme.vars.palette.text.secondary, + '& svg': { fontSize: SERVICE_TILE.iconSize, display: 'block' }, +})) + +// `minWidth: 0` para que el operador largo se recorte en vez de empujar el +// estado fuera de la tarjeta. +export const ShipmentIdentity = styled(Box)({ + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + minWidth: 0, +}) + +export const CourierName = styled(Typography)(({ theme }) => ({ + fontWeight: theme.typography.labelMd.fontWeight, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +})) + +// El seguimiento va en monoespaciada: es un código que se compara carácter a +// carácter contra el que muestra el courier. +export const TrackingLine = styled(Typography)(({ theme }) => ({ + fontFamily: theme.typography.dataMono.fontFamily, + color: theme.vars.palette.text.secondary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +})) diff --git a/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.tsx b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.tsx new file mode 100644 index 0000000..d36d844 --- /dev/null +++ b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.tsx @@ -0,0 +1,103 @@ +import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined' +import { Skeleton, Typography } from '@mui/material' +import { StatusBadge } from 'shared/components' + +import { dashboardCopy } from '../../content' + +import { + CourierName, + RowLink, + HeaderRow, + ListCard, + ShipmentIdentity, + ShipmentRow, + ShipmentRows, + ShipmentTile, + SKELETON_ROWS, + SKELETON_TILE, + TrackingLine, +} from './RecentShipmentsCard.styles' +import type { RecentShipmentsCardProps } from './RecentShipmentsCard.types' + +const shipmentsCopy = dashboardCopy.shipments + +// El tono del estado, con el mismo vocabulario que el resto del producto. Mapa +// explícito y no un `switch` con default: si el backend suma un estado, el +// compilador marca este archivo en vez de pintarlo en gris sin avisar. +const TONES = { + pending: 'neutral', + ready_to_ship: 'info', + in_transit: 'info', + delivered: 'success', +} as const + +/** + * «Últimos envíos» del panel (TESIS-163). + * + * Reemplaza a la tarjeta de Integraciones, que mostraba la salud de los nodos: + * las conexiones las administra el equipo y no la empresa, y lo que al operador + * le sirve de esa columna es qué salió, con quién y en qué anda. + * + * Sale de `GET /shipments`, que existe paginado desde TESIS-113 y hasta ahora + * sólo alimentaba el KPI de envíos activos. + */ +export function RecentShipmentsCard({ shipments, loading = false }: RecentShipmentsCardProps) { + const rows = loading + ? SKELETON_ROWS.map((id) => ( + + + + + )) + : shipments.map((shipment) => ( + // La fila entera lleva a la orden: el detalle del envío vive ahí, no en + // una pantalla propia. + + + + + + + {/* El operador se asigna al confirmar el despacho (TESIS-47): hasta + entonces se dice que no hay, en vez de dejar la línea vacía. */} + + {shipment.courier ?? shipmentsCopy.noCourier} + + + {shipment.trackingNumber ?? shipmentsCopy.noTracking} + + + + + + )) + + return ( + + + {shipmentsCopy.title} + + + + + {loading ? '' : shipmentsCopy.subtitle(shipments.length)} + + + {!loading && shipments.length === 0 ? ( + + {shipmentsCopy.empty} + + ) : ( + {rows} + )} + + ) +} diff --git a/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.types.ts b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.types.ts new file mode 100644 index 0000000..09dc68a --- /dev/null +++ b/src/features/dashboard/components/RecentShipmentsCard/RecentShipmentsCard.types.ts @@ -0,0 +1,8 @@ +import type { DispatchedShipment } from '../../types' + +export interface RecentShipmentsCardProps { + /** Los últimos envíos, en el orden que los devuelve la API. */ + shipments: DispatchedShipment[] + /** Muestra filas fantasma mientras llega la respuesta. */ + loading?: boolean +} diff --git a/src/features/dashboard/components/RecentShipmentsCard/index.ts b/src/features/dashboard/components/RecentShipmentsCard/index.ts new file mode 100644 index 0000000..da54a77 --- /dev/null +++ b/src/features/dashboard/components/RecentShipmentsCard/index.ts @@ -0,0 +1,2 @@ +export { RecentShipmentsCard } from './RecentShipmentsCard' +export type { RecentShipmentsCardProps } from './RecentShipmentsCard.types' diff --git a/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx b/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx index 2017088..3a3e295 100644 --- a/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx +++ b/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.tsx @@ -21,16 +21,16 @@ const copy = dashboardCopy.warehouses /** * Cuánto guarda cada depósito, ordenados de más a menos cargado. * - * El diseño dibuja acá un porcentaje de ocupación con tres tonos (azul, naranja, - * rojo) según qué tan lleno está el depósito. Eso no se puede mostrar: la tabla - * `warehouses` no tiene ninguna capacidad máxima contra la cual medir, y un - * porcentaje inventado pondría un número sin significado en un panel cuyo - * subtítulo promete datos en vivo. + * El diseño dibuja acá un porcentaje de ocupación. Desde TESIS-162 el depósito + * puede declarar su capacidad, así que el de los que la declararon es real: la + * barra mide cuánto de lo que entra está ocupado, y la fila lo dice. * - * Lo que sí es real es cuánto guarda cada uno, así que la barra compara los - * depósitos entre sí: el 100% es el más cargado. Por eso todas las barras van - * en el mismo tono —no codifican riesgo, codifican proporción— y el epígrafe - * dice contra qué se mide, para que nadie lea ocupación donde no la hay. + * El de los que no la declararon sigue sin existir, y ahí la barra compara los + * depósitos entre sí —el 100 % es el más cargado—, que es la única comparación + * real que queda. Un porcentaje de ocupación sin techo sería inventado. + * + * Por eso todas las barras van en el mismo tono: no codifican riesgo, y lo que + * cada una mide lo dice su fila y su rótulo accesible. * * Presentacional: recibe los depósitos ya ordenados y con su proporción. */ @@ -55,7 +55,11 @@ export function WarehouseLoadCard({ ) : ( <> - {warehouses.length === 0 ? copy.empty : copy.caption(storedUnits)} + {storedUnits === undefined + ? copy.unavailable + : warehouses.length === 0 + ? copy.empty + : copy.caption(storedUnits)} @@ -63,17 +67,39 @@ export function WarehouseLoadCard({ {warehouse.name} - {copy.units(warehouse.storedUnits)} + + {warehouse.measuredAgainstCapacity + ? copy.occupancy(warehouse.share) + : copy.units(warehouse.storedUnits)} + ))} + + {warehouses.some((warehouse) => !warehouse.measuredAgainstCapacity) && ( + + {copy.relativeNote} + + )} )} diff --git a/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.types.ts b/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.types.ts index 2f58eb5..dfaf093 100644 --- a/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.types.ts +++ b/src/features/dashboard/components/WarehouseLoadCard/WarehouseLoadCard.types.ts @@ -4,7 +4,8 @@ export interface WarehouseLoadCardProps { /** Depósitos ya ordenados y con su proporción, derivados por `warehouseShares`. */ warehouses: WarehouseShare[] /** Unidades guardadas entre todos: el epígrafe de la cabecera. */ - storedUnits: number + /** `undefined` si el listado no llegó: no es lo mismo que cero unidades. */ + storedUnits: number | undefined /** Muestra filas fantasma mientras llega la respuesta. */ loading?: boolean } diff --git a/src/features/dashboard/content.ts b/src/features/dashboard/content.ts index 468e037..96ae566 100644 --- a/src/features/dashboard/content.ts +++ b/src/features/dashboard/content.ts @@ -2,14 +2,12 @@ // idea que `content.ts` de design-system: si mañana sumamos i18n, este módulo es // el único punto a migrar a claves de traducción). +import { formatInteger } from 'shared/utils' + // Lo que muestra una tarjeta cuando el dato no está: falló la consulta o no hay // con qué calcularlo. Nunca un 0, que se leería como un dato real. const UNKNOWN_VALUE = '—' -// Separadores de miles del locale, para las unidades del widget de depósitos. -// El componente del DS recibe el valor ya formateado. -const UNITS_FORMAT = new Intl.NumberFormat('es-AR') - export const dashboardCopy = { pageTitle: 'Panel de operación', pageSubtitle: 'Datos en vivo de los centros de distribución.', @@ -22,6 +20,17 @@ export const dashboardCopy = { pendingOrders: { label: 'Órdenes pendientes', }, + // Reemplaza al KPI de «Salud del sistema» (TESIS-163): la misma salud de + // las integraciones, dicha como un número sobre el que se puede actuar. + // Reemplaza al KPI de «Salud del sistema» (TESIS-163), que mostraba un + // porcentaje derivado de la frescura de los nodos. Las unidades guardadas + // son el inventario del que vive la operación, y salen del mismo dato que + // ya alimenta la carga por depósito: ni un request más. + storedUnits: { + label: 'Unidades en stock', + note: (warehouses: number) => + warehouses === 1 ? 'en 1 depósito' : `repartidas en ${warehouses} depósitos`, + }, // Cuarta tarjeta de la fila del diseño. El chip y la nota son los del // MetricCard de S03-Panel; el tono `error` es lo que le da el borde de // acento que el diseño marca como `critical`. @@ -32,8 +41,8 @@ export const dashboardCopy = { // umbral no son lo mismo y se trabajan distinto, pero los dos son alerta. note: (outOfStock: number, low: number) => outOfStock === 0 - ? `${UNITS_FORMAT.format(low)} por debajo del umbral` - : `${UNITS_FORMAT.format(outOfStock)} sin stock · ${UNITS_FORMAT.format(low)} por debajo del umbral`, + ? `${formatInteger(low)} por debajo del umbral` + : `${formatInteger(outOfStock)} sin stock · ${formatInteger(low)} por debajo del umbral`, // Sin productos en alerta la tarjeta no grita: el borde rojo y el chip // «Crítico» afirmarían un problema que no existe. calmNote: 'Sin productos en alerta de stock', @@ -54,11 +63,23 @@ export const dashboardCopy = { warehouses: { title: 'Carga por depósito', caption: (units: number) => - `${UNITS_FORMAT.format(units)} ${units === 1 ? 'unidad guardada' : 'unidades guardadas'} · la barra compara contra el depósito más cargado`, - units: (units: number) => `${UNITS_FORMAT.format(units)} u`, + `${formatInteger(units)} ${units === 1 ? 'unidad guardada' : 'unidades guardadas'}`, + units: (units: number) => `${formatInteger(units)} u`, + // Con capacidad declarada (TESIS-162) se muestra la ocupación; sin ella, lo + // guardado a secas. El rótulo accesible dice contra qué se mide, porque las + // dos barras se ven igual y significan cosas distintas. + occupancy: (percentage: number) => `${Math.round(percentage)} % de su capacidad`, barLabel: (name: string, units: number) => - `${name}: ${UNITS_FORMAT.format(units)} ${units === 1 ? 'unidad' : 'unidades'}`, + `${name}: ${formatInteger(units)} ${units === 1 ? 'unidad' : 'unidades'}`, + barLabelWithCapacity: (name: string, units: number, capacity: number) => + `${name}: ${formatInteger(units)} de ${formatInteger(capacity)} unidades de capacidad`, + // Las dos barras se ven igual y miden cosas distintas: una la ocupación + // declarada y la otra la comparación contra el depósito más cargado. El + // rótulo accesible lo dice por fila; esto lo dice para quien mira. + relativeNote: 'Los depósitos sin capacidad declarada se comparan con el más cargado.', empty: 'La empresa no tiene depósitos cargados.', + /** La lista vacía por un error no es la lista vacía de una empresa sin depósitos. */ + unavailable: 'No pudimos cargar la carga por depósito.', }, // Tabla de órdenes recientes del panel (TESIS-56). Vocabulario y orden de // columnas de S03-Panel. @@ -85,36 +106,27 @@ export const dashboardCopy = { cancelled: 'Cancelada', }, }, - infra: { - health: { - // Vocabulario del diseño (S03-Panel): "Salud del sistema", no "de infraestructura". - label: 'Salud del sistema', - unknownValue: UNKNOWN_VALUE, - }, - nodes: { - title: 'Integraciones', - subtitleSynced: (online: number, reporting: number) => - `${online}/${reporting} ${reporting === 1 ? 'nodo sincronizado' : 'nodos sincronizados'}`, - subtitleNoReports: (active: number) => - `${active} ${active === 1 ? 'integración activa' : 'integraciones activas'} · sin datos de sincronización`, - empty: 'La empresa no tiene integraciones activas.', - // Línea inferior de cada fila. El diseño la usa como frase de estado - // ("Sincronizado hace 2 ms"), no como un timestamp suelto. - sync: { - online: (elapsed: string) => `Sincronizado ${elapsed}`, - stale: (elapsed: string) => `Sin sincronizar desde ${elapsed}`, - unknown: 'Sin datos de sincronización', - }, - // Texto accesible del ícono de estado. - status: { - online: 'Sincronizado', - stale: 'Sincronización atrasada', - unknown: 'Sin datos de sincronización', - }, - types: { - ecommerce: 'E-commerce', - courier: 'Courier', - }, + // La tarjeta «Últimos envíos» reemplaza a la de Integraciones (TESIS-163): + // las conexiones las administra el equipo, no la empresa, y lo que sí le + // sirve al operador es qué salió y con quién. + shipments: { + title: 'Últimos envíos', + subtitle: (count: number) => (count === 1 ? '1 envío reciente' : `${count} envíos recientes`), + empty: 'Todavía no se despachó ningún envío.', + noCourier: 'Sin operador asignado', + noTracking: 'Sin seguimiento', + /** "Orden #8829" — nombre accesible de la fila. */ + order: (id: number) => `Orden #${id}`, + /** La fila entera es un enlace: el nombre accesible dice a dónde lleva. */ + openOrder: (id: number) => `Ver la orden #${id}`, + // El destino se declara acá y no se importa del router: una feature no + // puede depender de `app/` (architecture.md §3.2). + orderPath: (id: number) => `/orders/${id}`, + status: { + pending: 'Pendiente', + ready_to_ship: 'Listo para despachar', + in_transit: 'En tránsito', + delivered: 'Entregado', }, }, error: { diff --git a/src/features/dashboard/hooks/useInfraHealth.ts b/src/features/dashboard/hooks/useInfraHealth.ts deleted file mode 100644 index 4dbcad0..0000000 --- a/src/features/dashboard/hooks/useInfraHealth.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useCallback, useMemo } from 'react' -import type { IntegrationService, ServiceType } from 'shared/api' -import type { StatTone } from 'shared/components' -import { useIntegrations } from 'shared/hooks/useIntegrations' - -import type { NodeSyncStatus } from '../types' - -import { useNow } from './useNow' - -// Ventana de frescura: una sync más vieja que esto se considera fuera de los -// parámetros normales de latencia y prende el LED en rojo. -// -// El valor es provisorio: la card no fija un umbral y el worker de sync -// (TESIS-35) todavía no define su cadencia. Cuando exista, este número tiene que -// pasar a ser ~2x el intervalo del worker. -export const SYNC_STALE_THRESHOLD_MS = 15 * 60 * 1000 - -// Cada cuánto avanza el reloj del widget. 30s alcanza: los tiempos se muestran -// con granularidad de minuto. -const CLOCK_INTERVAL_MS = 30 * 1000 - -// Cortes de salud para el color del KPI. Por encima de `healthy` está todo bien; -// entre medio hay nodos caídos pero el sistema opera; por debajo es incidente. -const HEALTH_THRESHOLD = { healthy: 90, degraded: 50 } - -export interface InfraNode { - serviceId: number - name: string - type: ServiceType - status: NodeSyncStatus - /** ISO de la última sync exitosa, o `null` si el backend todavía no la manda. */ - lastSyncedAt: string | null -} - -export interface InfraHealth { - /** Solo los nodos con integración configurada y activa, ordenados por nombre. */ - nodes: InfraNode[] - activeNodes: number - /** Nodos activos que sí traen una marca de sync válida (denominador del KPI). */ - reportingNodes: number - onlineNodes: number - /** Porcentaje 0-100, o `null` si ningún nodo reporta sync todavía. */ - healthPercentage: number | null - healthTone: StatTone - isLoading: boolean - isError: boolean - error: Error | null - refetch: () => void -} - -// `last_synced_at` puede no venir (contrato actual), venir `null` (nunca -// sincronizó) o traer una fecha inválida. Los tres casos son `unknown`: no -// sabemos el estado del nodo y no lo damos por sano. -function resolveStatus(lastSyncedAt: string | null, now: number): NodeSyncStatus { - if (!lastSyncedAt) return 'unknown' - - const syncedAt = new Date(lastSyncedAt).getTime() - if (Number.isNaN(syncedAt)) return 'unknown' - - return now - syncedAt <= SYNC_STALE_THRESHOLD_MS ? 'online' : 'stale' -} - -function toNode(integration: IntegrationService, now: number): InfraNode { - return { - serviceId: integration.serviceId, - name: integration.name, - type: integration.type, - status: resolveStatus(integration.lastSyncedAt, now), - lastSyncedAt: integration.lastSyncedAt, - } -} - -function resolveTone(healthPercentage: number | null): StatTone { - if (healthPercentage === null) return 'neutral' - if (healthPercentage >= HEALTH_THRESHOLD.healthy) return 'success' - if (healthPercentage >= HEALTH_THRESHOLD.degraded) return 'warning' - return 'error' -} - -// Deriva los nodos y el KPI de salud de la lista que ya trajo React Query: es una -// proyección memoizada sobre `data`, sin estado duplicado ni fetch extra. La -// dependencia de `now` es lo que hace que un nodo pase a `stale` con el paso del -// tiempo, sin necesidad de refetch. -export function useInfraHealth(): InfraHealth { - const { data, isLoading, isError, error, refetch } = useIntegrations() - const now = useNow(CLOCK_INTERVAL_MS) - - // Envuelto: `refetch` de React Query recibe `RefetchOptions`, y cablearlo - // directo a un `onClick` le pasaría el MouseEvent como opciones. - const retry = useCallback(() => { - void refetch() - }, [refetch]) - - const nodes = useMemo( - () => - (data ?? []) - // El filtro que pide la card: solo services con integración existente y - // activa. Los demás son servicios disponibles, no nodos de la empresa. - .filter((integration) => integration.configured && integration.isActive) - .map((integration) => toNode(integration, now)) - .sort((a, b) => a.name.localeCompare(b.name, 'es-AR')), - [data, now], - ) - - const counts = useMemo(() => { - const online = nodes.filter((node) => node.status === 'online').length - const reporting = nodes.filter((node) => node.status !== 'unknown').length - - return { online, reporting } - }, [nodes]) - - // Denominador = nodos que reportan, no todos los activos. Mientras el backend - // no mande la marca de sync, `reporting` es 0 y el KPI queda en `null` ("—"): - // preferimos no saber antes que mostrar 0% (falsa caída total) o 100% (falsa - // salud) sobre un dato que no existe. - const healthPercentage = - counts.reporting === 0 ? null : Math.round((counts.online / counts.reporting) * 100) - - return { - nodes, - activeNodes: nodes.length, - reportingNodes: counts.reporting, - onlineNodes: counts.online, - healthPercentage, - healthTone: resolveTone(healthPercentage), - isLoading, - isError, - error, - refetch: retry, - } -} diff --git a/src/features/dashboard/hooks/useInventoryAlerts.test.tsx b/src/features/dashboard/hooks/useInventoryAlerts.test.tsx new file mode 100644 index 0000000..50924a0 --- /dev/null +++ b/src/features/dashboard/hooks/useInventoryAlerts.test.tsx @@ -0,0 +1,62 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import * as api from '../api' + +import { useInventoryAlerts } from './useInventoryAlerts' + +const LOADS = [ + { id: 1, name: 'CD Norte', storedUnits: 200, capacity: null }, + { id: 2, name: 'CD Sur', storedUnits: 50, capacity: null }, +] + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + + return {children} +} + +beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(api, 'fetchStockAlertCounts').mockResolvedValue({ low: 18, outOfStock: 6 }) +}) + +describe('useInventoryAlerts', () => { + it('adds up the units of every warehouse', async () => { + vi.spyOn(api, 'fetchWarehouseLoads').mockResolvedValue(LOADS) + const { result } = renderHook(() => useInventoryAlerts(), { wrapper }) + + await waitFor(() => expect(result.current.storedUnits).toBe(250)) + }) + + // Sumar sobre la lista vacía daría 0, y un cero es un dato real: la empresa + // no guarda nada. Que la consulta se haya caído no es eso. + it('has no number at all when the warehouses never arrived', async () => { + vi.spyOn(api, 'fetchWarehouseLoads').mockRejectedValue(new Error('boom')) + const { result } = renderHook(() => useInventoryAlerts(), { wrapper }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(result.current.storedUnits).toBeUndefined() + }) + + // Que se caiga el listado de depósitos no tiene por qué dejar sin número a la + // tarjeta de alertas: son dos preguntas distintas. + it('keeps the alert count when only the warehouses failed', async () => { + vi.spyOn(api, 'fetchWarehouseLoads').mockRejectedValue(new Error('boom')) + const { result } = renderHook(() => useInventoryAlerts(), { wrapper }) + + await waitFor(() => expect(result.current.alerts.value).toBe(24)) + }) + + // Una empresa sin depósitos sí guarda cero unidades, y eso se dice. + it('says zero for a company that really has no warehouses', async () => { + vi.spyOn(api, 'fetchWarehouseLoads').mockResolvedValue([]) + const { result } = renderHook(() => useInventoryAlerts(), { wrapper }) + + await waitFor(() => expect(result.current.storedUnits).toBe(0)) + }) +}) diff --git a/src/features/dashboard/hooks/useInventoryAlerts.ts b/src/features/dashboard/hooks/useInventoryAlerts.ts index 461de9d..f13cb20 100644 --- a/src/features/dashboard/hooks/useInventoryAlerts.ts +++ b/src/features/dashboard/hooks/useInventoryAlerts.ts @@ -17,8 +17,12 @@ export interface InventoryOverview { breakdown: StockAlertCounts | undefined /** Depósitos ordenados de más a menos cargado, con el ancho de su barra. */ warehouses: WarehouseShare[] - /** Unidades guardadas entre todos los depósitos. */ - storedUnits: number + /** + * Unidades guardadas entre todos los depósitos, o `undefined` si el listado + * no llegó. Sumar sobre una lista vacía daría 0, que es un dato real —la + * empresa no guarda nada— y acá no lo sabemos. + */ + storedUnits: number | undefined warehousesLoading: boolean /** Alguna de las dos consultas falló. */ isError: boolean @@ -78,7 +82,7 @@ export function useInventoryAlerts(): InventoryOverview { }, breakdown: alerts.data, warehouses: warehouseShares(loads), - storedUnits: totalStoredUnits(loads), + storedUnits: warehouses.data === undefined ? undefined : totalStoredUnits(warehouses.data), warehousesLoading: warehouses.isLoading, isError: alerts.isError || warehouses.isError, refetch, diff --git a/src/features/dashboard/hooks/useNow.ts b/src/features/dashboard/hooks/useNow.ts deleted file mode 100644 index f273720..0000000 --- a/src/features/dashboard/hooks/useNow.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useEffect, useState } from 'react' - -// Reloj de UI: re-renderiza cada `intervalMs` para que el tiempo relativo del -// nodo ("hace 2 minutos") avance y el LED cruce el umbral de frescura solo, -// sin depender de un refetch. Es lo que pide el criterio "el LED reacciona si -// la fecha de actualización es demasiado antigua": la fecha no cambia, cambia -// el ahora contra el que la comparamos. -// -// No contradice la regla de "nunca useEffect + useState": esa aplica al fetch -// de datos (que va por React Query). Acá no se busca nada al servidor, es un -// tick local. -export function useNow(intervalMs: number): number { - const [now, setNow] = useState(() => Date.now()) - - useEffect(() => { - const id = window.setInterval(() => setNow(Date.now()), intervalMs) - return () => window.clearInterval(id) - }, [intervalMs]) - - return now -} diff --git a/src/features/dashboard/hooks/useRecentShipments.ts b/src/features/dashboard/hooks/useRecentShipments.ts new file mode 100644 index 0000000..2d139ec --- /dev/null +++ b/src/features/dashboard/hooks/useRecentShipments.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query' + +import { fetchRecentShipments, RECENT_SHIPMENTS } from '../api' +import { shipmentKeys } from '../queryKeys' +import type { DispatchedShipment } from '../types' + +export interface RecentShipmentsState { + shipments: DispatchedShipment[] + isLoading: boolean + isError: boolean + refetch: () => void +} + +/** + * Los últimos envíos que muestra el panel (TESIS-163). + * + * Reemplaza a la tarjeta de Integraciones, que mostraba la salud de los nodos. + * Mismo criterio que `useRecentOrders`: consulta propia y no una porción del + * listado, porque «los últimos cinco» y «la página N filtrada» son dos + * preguntas distintas y compartir la caché las mezclaría. + */ +export function useRecentShipments(): RecentShipmentsState { + const query = useQuery({ + queryKey: shipmentKeys.recent(RECENT_SHIPMENTS), + queryFn: fetchRecentShipments, + }) + + const { refetch } = query + + return { + shipments: query.data ?? [], + isLoading: query.isLoading, + isError: query.isError, + // Envuelto por lo mismo que en los otros hooks del panel: el `refetch` de + // React Query recibe opciones, y cablearlo a un `onClick` le pasaría el + // MouseEvent como opciones. + refetch: () => void refetch(), + } +} diff --git a/src/features/dashboard/index.ts b/src/features/dashboard/index.ts index a1c7cfe..3ce3bc4 100644 --- a/src/features/dashboard/index.ts +++ b/src/features/dashboard/index.ts @@ -1,10 +1,9 @@ export { DashboardPage } from './pages/DashboardPage' -export { useInfraHealth } from './hooks/useInfraHealth' -export type { InfraHealth, InfraNode } from './hooks/useInfraHealth' export { useInventoryAlerts } from './hooks/useInventoryAlerts' export type { InventoryOverview } from './hooks/useInventoryAlerts' export { useLogisticsKpis } from './hooks/useLogisticsKpis' export { useRecentOrders } from './hooks/useRecentOrders' +export { useRecentShipments } from './hooks/useRecentShipments' +export type { RecentShipmentsState } from './hooks/useRecentShipments' export type { RecentOrdersState } from './hooks/useRecentOrders' export type { KpiState, LogisticsKpis } from './hooks/useLogisticsKpis' -export type { NodeSyncStatus } from './types' diff --git a/src/features/dashboard/pages/DashboardPage.test.tsx b/src/features/dashboard/pages/DashboardPage.test.tsx index 8c6cb10..1e31a2c 100644 --- a/src/features/dashboard/pages/DashboardPage.test.tsx +++ b/src/features/dashboard/pages/DashboardPage.test.tsx @@ -3,16 +3,16 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithTheme } from '../../../test/renderWithTheme' -import { useInfraHealth } from '../hooks/useInfraHealth' import { useInventoryAlerts } from '../hooks/useInventoryAlerts' import { useLogisticsKpis } from '../hooks/useLogisticsKpis' import { useRecentOrders } from '../hooks/useRecentOrders' +import { useRecentShipments } from '../hooks/useRecentShipments' import type { RecentOrder } from '../types' import { DashboardPage } from './DashboardPage' vi.mock('../hooks/useLogisticsKpis', () => ({ useLogisticsKpis: vi.fn() })) -vi.mock('../hooks/useInfraHealth', () => ({ useInfraHealth: vi.fn() })) +vi.mock('../hooks/useRecentShipments', () => ({ useRecentShipments: vi.fn() })) vi.mock('../hooks/useInventoryAlerts', () => ({ useInventoryAlerts: vi.fn() })) vi.mock('../hooks/useRecentOrders', () => ({ useRecentOrders: vi.fn() })) @@ -48,8 +48,22 @@ function mockOrders(overrides: Partial> = {}) } const WAREHOUSES = [ - { id: 1, name: 'CD Norte', storedUnits: 200, share: 100 }, - { id: 2, name: 'CD Sur', storedUnits: 50, share: 25 }, + { + id: 1, + name: 'CD Norte', + storedUnits: 200, + capacity: null, + share: 100, + measuredAgainstCapacity: false, + }, + { + id: 2, + name: 'CD Sur', + storedUnits: 50, + capacity: null, + share: 25, + measuredAgainstCapacity: false, + }, ] // 18 por debajo del umbral y 6 agotados: 24 en alerta. El desglose importa @@ -87,20 +101,45 @@ beforeEach(() => { isError: false, refetch: vi.fn(), }) - vi.mocked(useInfraHealth).mockReturnValue({ - nodes: [], - reportingNodes: 0, - onlineNodes: 0, - healthPercentage: null, - healthTone: 'neutral', + vi.mocked(useRecentShipments).mockReturnValue({ + shipments: [], isLoading: false, isError: false, refetch: vi.fn(), - } as never) + }) mockInventory() mockOrders() }) +// Una lista de depósitos que no llegó no es una empresa sin unidades: sumar +// sobre la lista vacía daría 0, que es un dato real y acá no lo sabemos. +describe('DashboardPage · stored units when the warehouses never arrived', () => { + it('shows no number instead of claiming zero units', () => { + mockInventory({ storedUnits: undefined, warehouses: [], isError: true }) + renderPage() + + const card = screen.getByText('Unidades en stock').closest('div') + + expect(within(card as HTMLElement).queryByText('0')).not.toBeInTheDocument() + expect(within(card as HTMLElement).getByText('—')).toBeInTheDocument() + }) + + it('does not say the company has zero warehouses either', () => { + mockInventory({ storedUnits: undefined, warehouses: [], isError: true }) + renderPage() + + expect(screen.queryByText(/repartidas en 0 depósitos/)).not.toBeInTheDocument() + }) + + it('says the warehouse load could not be loaded, not that there are none', () => { + mockInventory({ storedUnits: undefined, warehouses: [], isError: true }) + renderPage() + + expect(screen.getByText('No pudimos cargar la carga por depósito.')).toBeInTheDocument() + expect(screen.queryByText('La empresa no tiene depósitos cargados.')).not.toBeInTheDocument() + }) +}) + describe('DashboardPage · inventory alerts', () => { // El agotado también es alerta: `low` en el backend es `BETWEEN 1 AND umbral`, // así que contar sólo eso dejaba afuera los productos que ya no se venden. @@ -223,12 +262,62 @@ describe('DashboardPage · warehouse load', () => { expect(screen.getByText('50 u')).toBeInTheDocument() }) - // Lo que evita que alguien lea ocupación donde hay una comparación. - it('says out loud what the bar is measured against', () => { + // Lo que evita que alguien lea ocupación donde hay una comparación: desde + // TESIS-162 cada fila dice contra qué se mide, porque las dos barras se ven + // igual y significan cosas distintas. + it('shows the units of a warehouse that declared no capacity', () => { + renderPage() + + expect(screen.getByText('200 u')).toBeInTheDocument() + expect(screen.queryByText(/de su capacidad/)).not.toBeInTheDocument() + }) + + // «200 u» no dice contra qué se mide la barra. El rótulo accesible lo dice + // por fila; abajo de la lista se dice también para quien mira. + it('says what the bars without a declared capacity are measured against', () => { + renderPage() + + expect( + screen.getByText('Los depósitos sin capacidad declarada se comparan con el más cargado.'), + ).toBeInTheDocument() + }) + + it('does not say it when every warehouse declared its capacity', () => { + mockInventory({ + warehouses: [ + { + id: 1, + name: 'CD Norte', + storedUnits: 200, + capacity: 1000, + share: 20, + measuredAgainstCapacity: true, + }, + ], + }) + renderPage() + + expect(screen.queryByText(/se comparan con el más cargado/)).not.toBeInTheDocument() + }) + + it('shows the occupancy of a warehouse that declared one', () => { + mockInventory({ + warehouses: [ + { + id: 1, + name: 'CD Norte', + storedUnits: 200, + capacity: 1_000, + share: 20, + measuredAgainstCapacity: true, + }, + ], + }) renderPage() + expect(screen.getByText('20 % de su capacidad')).toBeInTheDocument() expect( - screen.getByText(/250 unidades guardadas · la barra compara contra el depósito más cargado/), + screen.getByLabelText('CD Norte: 200 de 1.000 unidades de capacidad'), ).toBeInTheDocument() }) diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx index 52083d2..a2dff97 100644 --- a/src/features/dashboard/pages/DashboardPage.tsx +++ b/src/features/dashboard/pages/DashboardPage.tsx @@ -1,22 +1,23 @@ +import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined' import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined' -import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined' import ScheduleOutlinedIcon from '@mui/icons-material/ScheduleOutlined' import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined' import { Alert, Box, Button, Grid, Stack, Typography } from '@mui/material' import { Link as RouterLink } from 'react-router-dom' import { PageWrapper, StatCard } from 'shared/components' +import { formatInteger } from 'shared/utils' -import { IntegrationNodeList } from '../components/IntegrationNodeList' import { RecentOrdersTable } from '../components/RecentOrdersTable' +import { RecentShipmentsCard } from '../components/RecentShipmentsCard' import { WarehouseLoadCard } from '../components/WarehouseLoadCard' import { dashboardCopy } from '../content' -import { useInfraHealth } from '../hooks/useInfraHealth' import { useInventoryAlerts } from '../hooks/useInventoryAlerts' import { useLogisticsKpis } from '../hooks/useLogisticsKpis' import { useRecentOrders } from '../hooks/useRecentOrders' +import { useRecentShipments } from '../hooks/useRecentShipments' -const { metrics, infra, error: errorCopy } = dashboardCopy -const healthCopy = infra.health +const { metrics, error: errorCopy } = dashboardCopy +const storedUnitsCopy = metrics.storedUnits const alertsCopy = metrics.inventoryAlerts // Destino del click en la tarjeta de alertas. Las rutas se registran en @@ -58,12 +59,10 @@ function noteFor(breakdown: { low: number; outOfStock: number } | undefined): st // `StatCard` recibe el valor ya formateado: el componente del DS no decide // separadores ni unidades. -const NUMBER_FORMAT = new Intl.NumberFormat('es-AR') - // Un conteo que no llegó (consulta fallida) se muestra como "—", nunca como 0: // un cero es un dato real —no hay órdenes pendientes— y acá no lo sabemos. function formatCount(count: number | undefined): string { - return count === undefined ? metrics.unknownValue : NUMBER_FORMAT.format(count) + return count === undefined ? metrics.unknownValue : formatInteger(count) } export function DashboardPage() { @@ -74,17 +73,6 @@ export function DashboardPage() { refetch: refetchKpis, } = useLogisticsKpis() - const { - nodes, - reportingNodes, - onlineNodes, - healthPercentage, - healthTone, - isLoading: isInfraLoading, - isError: isInfraError, - refetch: refetchInfra, - } = useInfraHealth() - const { alerts, breakdown, @@ -102,13 +90,20 @@ export function DashboardPage() { refetch: refetchOrders, } = useRecentOrders() - const isError = isKpisError || isInfraError || isInventoryError || isOrdersError + const { + shipments, + isLoading: shipmentsLoading, + isError: isShipmentsError, + refetch: refetchShipments, + } = useRecentShipments() + + const isError = isKpisError || isInventoryError || isOrdersError || isShipmentsError const retry = () => { refetchKpis() - refetchInfra() refetchInventory() refetchOrders() + refetchShipments() } // El tono de alerta se enciende sólo si hay algo que alertar: con cero @@ -118,14 +113,6 @@ export function DashboardPage() { const hasAlerts = alerts.value !== undefined && alerts.value > 0 const alertsValue = formatCount(alerts.value) - // Sin nodos reportando sync, el KPI no tiene numerador ni denominador reales: - // se muestra "—" en vez de un 0% que se leería como caída total de la - // infraestructura, o un 100% que afirmaría una salud que nadie verificó. - const healthValue = - isInfraLoading || healthPercentage === null - ? healthCopy.unknownValue - : `${NUMBER_FORMAT.format(healthPercentage)}%` - return ( @@ -170,11 +157,19 @@ export function DashboardPage() { /> + {/* Reemplaza al KPI de «Salud del sistema» (TESIS-163). Sale del + mismo dato que la carga por depósito, así que no agrega ningún + request. */} } - tone={healthTone} + label={storedUnitsCopy.label} + value={formatCount(storedUnits)} + loading={warehousesLoading} + icon={} + note={ + warehousesLoading || storedUnits === undefined + ? undefined + : storedUnitsCopy.note(warehouses.length) + } /> @@ -203,7 +198,8 @@ export function DashboardPage() { {/* La fila inferior del diseño: la tabla de órdenes recientes y, a su - derecha, la columna de 280px con integraciones y carga de depósitos. + derecha, la columna de 280px con los últimos envíos y la carga de + depósitos. En pantallas angostas la columna baja debajo de la tabla. */} @@ -211,12 +207,7 @@ export function DashboardPage() { - + [...shipmentKeys.all, 'kpi', status] as const, + // Mismo criterio que `orderKeys.recent`: despachar un envío invalida el + // dominio y refresca también la tarjeta del panel. + recent: (size: number) => [...shipmentKeys.all, 'recent', size] as const, } // Misma regla que los KPIs de órdenes y envíos: la key cuelga de la raíz del diff --git a/src/features/dashboard/types.ts b/src/features/dashboard/types.ts index 8998ecf..9ce74dc 100644 --- a/src/features/dashboard/types.ts +++ b/src/features/dashboard/types.ts @@ -1,22 +1,40 @@ -// Estado de frescura derivado de `last_synced_at`: -// - `online` → sincronizó dentro de la ventana esperada (ícono `check_circle`) -// - `stale` → la última sync quedó fuera de la ventana (ícono `error`) -// - `unknown` → no hay marca de sync: no afirmamos nada (ícono neutro) -export type NodeSyncStatus = 'online' | 'stale' | 'unknown' - /** * Un depósito y las unidades que guarda, para el widget de carga del panel * (TESIS-55). * - * No hay porcentaje de ocupación y no es un olvido: `warehouses` no tiene - * ninguna capacidad máxima contra la cual medirlo. La barra del widget compara - * los depósitos entre sí, y de eso se encarga `warehouseShares`. + * Desde TESIS-162 el depósito puede declarar su capacidad, y entonces la barra + * mide ocupación de verdad. Sin capacidad declarada sigue comparando los + * depósitos entre sí, que es la única comparación real que queda. */ export interface WarehouseLoad { id: number name: string /** Suma de las unidades en stock del depósito. Cero es un dato, no un faltante. */ storedUnits: number + /** + * Capacidad declarada, en unidades. `null` cuando nadie la cargó: no es cero, + * que diría que no entra nada. + */ + capacity: number | null +} + +/** Los cuatro estados del envío (`Shipment::STATUSES`). */ +export type ShipmentStatus = 'pending' | 'ready_to_ship' | 'in_transit' | 'delivered' + +/** + * Una fila de la tarjeta «Últimos envíos» del panel (TESIS-163). + * + * Reemplaza a la tarjeta de Integraciones, que mostraba la salud de los nodos. + * `trackingNumber` y `courier` llegan vacíos mientras el envío está `pending`: + * los dos los asigna el courier al confirmar el despacho (TESIS-47). + */ +export interface DispatchedShipment { + id: number + orderId: number + status: ShipmentStatus + trackingNumber: string | null + courier: string | null + createdAt: string } /** Los tres estados que el backend acepta (`Order::STATUSES`). */ diff --git a/src/features/dashboard/utils/capacity.test.ts b/src/features/dashboard/utils/capacity.test.ts index a2ae4fa..72e3126 100644 --- a/src/features/dashboard/utils/capacity.test.ts +++ b/src/features/dashboard/utils/capacity.test.ts @@ -4,8 +4,13 @@ import type { WarehouseLoad } from '../types' import { totalStoredUnits, warehouseShares } from './capacity' -function warehouse(id: number, name: string, storedUnits: number): WarehouseLoad { - return { id, name, storedUnits } +function warehouse( + id: number, + name: string, + storedUnits: number, + capacity: number | null = null, +): WarehouseLoad { + return { id, name, storedUnits, capacity } } describe('warehouseShares', () => { @@ -62,3 +67,60 @@ describe('totalStoredUnits', () => { expect(totalStoredUnits([])).toBe(0) }) }) + +// TESIS-162: con capacidad declarada la barra deja de comparar depósitos y +// pasa a medir ocupación. +describe('warehouseShares with a declared capacity', () => { + it('measures the warehouse against its own capacity', () => { + const shares = warehouseShares([warehouse(1, 'Norte', 300, 1_000)]) + + expect(shares[0]).toMatchObject({ share: 30, measuredAgainstCapacity: true }) + }) + + // Un depósito pasado de su techo está lleno, no 140 % lleno. + it('caps a warehouse that holds more than it declared', () => { + const shares = warehouseShares([warehouse(1, 'Norte', 1_400, 1_000)]) + + expect(shares[0].share).toBe(100) + }) + + // Una empresa puede haber declarado unos y otros no. + it('keeps comparing the ones that declared nothing against the fullest', () => { + const shares = warehouseShares([ + warehouse(1, 'Norte', 200, 1_000), + warehouse(2, 'Sur', 50), + warehouse(3, 'Centro', 100), + ]) + + expect(shares.find((w) => w.name === 'Norte')).toMatchObject({ + share: 20, + measuredAgainstCapacity: true, + }) + expect(shares.find((w) => w.name === 'Sur')).toMatchObject({ + share: 25, + measuredAgainstCapacity: false, + }) + }) +}) + +// Un techo en cero no define ninguna ocupación, y `0 / 0` daría `NaN` en el +// ancho de la barra. +describe('warehouseShares with a capacity of zero', () => { + it('does not turn the bar into NaN', () => { + const shares = warehouseShares([warehouse(1, 'Norte', 0, 0)]) + + expect(Number.isFinite(shares[0].share)).toBe(true) + expect(shares[0].share).toBe(0) + }) + + // Sin techo utilizable la barra vuelve a comparar contra el más cargado, y + // el rótulo tiene que decir eso y no «% de su capacidad». + it('measures it against the fullest warehouse instead', () => { + const shares = warehouseShares([warehouse(1, 'Norte', 50, 0), warehouse(2, 'Sur', 100)]) + + expect(shares.find((w) => w.name === 'Norte')).toMatchObject({ + share: 50, + measuredAgainstCapacity: false, + }) + }) +}) diff --git a/src/features/dashboard/utils/capacity.ts b/src/features/dashboard/utils/capacity.ts index 13cd728..155ddc3 100644 --- a/src/features/dashboard/utils/capacity.ts +++ b/src/features/dashboard/utils/capacity.ts @@ -5,20 +5,30 @@ import type { WarehouseLoad } from '../types' /** Un depósito con el ancho que le toca en la barra. */ export interface WarehouseShare extends WarehouseLoad { - /** 0-100. Proporción respecto del depósito más cargado, no de una capacidad. */ + /** + * 0-100. Ocupación real si el depósito declaró su capacidad (TESIS-162); si + * no, proporción respecto del depósito más cargado. + */ share: number + /** Qué mide la barra de esta fila, para poder decirlo en su rótulo. */ + measuredAgainstCapacity: boolean } /** * Los depósitos ordenados de más a menos cargado, con el ancho de cada barra. * - * La referencia del 100% es el depósito más cargado y no una capacidad máxima: - * `warehouses` no tiene ninguna, así que un porcentaje de ocupación sería un - * número inventado. Lo que la barra dice es «este guarda la mitad que el que - * más guarda», que es una comparación real. + * Con capacidad declarada (TESIS-162) la barra mide **ocupación**: cuánto de lo + * que entra está ocupado. Sin capacidad la referencia sigue siendo el depósito + * más cargado, porque esa es la única comparación real que queda: un porcentaje + * de ocupación sin techo sería un número inventado. + * + * Los dos casos conviven: una empresa puede haber declarado la capacidad de + * algunos depósitos y no de otros, y cada fila dice contra qué se mide. * * Con todos los depósitos vacíos el máximo es cero: todas las barras quedan en - * cero en vez de dividir por cero y dar `NaN`. + * cero en vez de dividir por cero y dar `NaN`. Por encima de la capacidad la + * barra se corta en 100: un depósito pasado de su techo está lleno, no 140 % + * lleno. */ export function warehouseShares(warehouses: WarehouseLoad[]): WarehouseShare[] { const busiest = Math.max(0, ...warehouses.map((warehouse) => warehouse.storedUnits)) @@ -27,10 +37,21 @@ export function warehouseShares(warehouses: WarehouseLoad[]): WarehouseShare[] { .sort((a, b) => b.storedUnits - a.storedUnits) .map((warehouse) => ({ ...warehouse, - share: busiest === 0 ? 0 : (warehouse.storedUnits / busiest) * 100, + share: shareOf(warehouse, busiest), + measuredAgainstCapacity: warehouse.capacity !== null && warehouse.capacity > 0, })) } +function shareOf(warehouse: WarehouseLoad, busiest: number): number { + // `> 0` y no `!== null`: un techo en cero no define ninguna ocupación, y + // `0 / 0` daría `NaN` en el ancho de la barra. + if (warehouse.capacity !== null && warehouse.capacity > 0) { + return Math.min(100, (warehouse.storedUnits / warehouse.capacity) * 100) + } + + return busiest === 0 ? 0 : (warehouse.storedUnits / busiest) * 100 +} + /** Unidades guardadas entre todos los depósitos. Es el epígrafe de la tarjeta. */ export function totalStoredUnits(warehouses: WarehouseLoad[]): number { return warehouses.reduce((total, warehouse) => total + warehouse.storedUnits, 0) diff --git a/src/features/dashboard/utils/recentOrders.test.ts b/src/features/dashboard/utils/recentOrders.test.ts index b3e6eef..1c8e2d9 100644 --- a/src/features/dashboard/utils/recentOrders.test.ts +++ b/src/features/dashboard/utils/recentOrders.test.ts @@ -1,30 +1,10 @@ import { describe, expect, it } from 'vitest' -import { formatMoney, formatOrderId, formatShortDate, formatTime } from './recentOrders' +import { formatOrderId, formatShortDate, formatTime } from './recentOrders' // `toContain` y no `toBe`: entre el símbolo y el número, `Intl` mete un espacio // duro (U+00A0) que en la comparación no se distingue de uno común y deja un // diff ilegible — «expected '$ 0,00' to be '$ 0,00'». -describe('formatMoney', () => { - // Criterio de finalización de la card: el monto se formatea como moneda. - it('writes the amount with the thousands separators of the locale', () => { - expect(formatMoney(1478300.49)).toContain('1.478.300,49') - }) - - it('leads with the currency symbol', () => { - expect(formatMoney(1478300.49)).toMatch(/^\$/) - }) - - // Redondear en pantalla sería mostrar un número que no es el de la orden. - it('never drops the cents, even when they are zero', () => { - expect(formatMoney(890050)).toContain('890.050,00') - }) - - it('handles an order that adds up to nothing', () => { - expect(formatMoney(0)).toContain('0,00') - }) -}) - describe('formatOrderId', () => { it('prefers the id of the external channel, which is how the operator knows it', () => { expect(formatOrderId('ORD-8829-X', 412)).toBe('#ORD-8829-X') diff --git a/src/features/dashboard/utils/recentOrders.ts b/src/features/dashboard/utils/recentOrders.ts index 0027c01..9892bac 100644 --- a/src/features/dashboard/utils/recentOrders.ts +++ b/src/features/dashboard/utils/recentOrders.ts @@ -5,20 +5,11 @@ import type { OrderStatus } from '../types' // Formato y vocabulario de la tabla de órdenes recientes del panel. // -// Sí, esto también existe en `features/orders/utils`. No se importa de ahí -// porque una feature no puede depender de otra (architecture.md §3.2), y no se -// sube a `shared/` en esta card por una razón concreta: TESIS-61 está -// reescribiendo esos archivos en paralelo, y moverlos ahora garantiza un -// conflicto en el trabajo de otro. Cuando esa card entre, converger las dos -// copias es una card de seguimiento — hasta entonces, las etiquetas de estado -// son las mismas de los dos lados a propósito y este comentario es el recordatorio. - -const MONEY = new Intl.NumberFormat('es-AR', { - style: 'currency', - currency: 'ARS', - minimumFractionDigits: 2, - maximumFractionDigits: 2, -}) +// El importe y las unidades salen de `shared/utils`: eran la misma cuenta que +// en `features/orders/utils`, y una feature no puede importar de otra +// (architecture.md §3.2). Converger las dos copias era la card de seguimiento +// que ese comentario anunciaba, y es TESIS-166. Las etiquetas de estado siguen +// repetidas a propósito: son vocabulario de pantalla, no formato. const DATE = new Intl.DateTimeFormat('es-AR', { day: 'numeric', month: 'short' }) @@ -52,17 +43,6 @@ export function statusLabel(status: OrderStatus): string { return LABELS[status] } -/** - * Importe de la orden, con dos decimales siempre. - * - * El criterio de finalización de la card es exactamente éste: la columna es - * plata facturada, y redondear $ 1.478.300,49 a $ 1.478.300 en pantalla es - * mostrar un número que no es el de la orden. - */ -export function formatMoney(amount: number): string { - return MONEY.format(amount) -} - /** "24 ago" — la línea principal de la columna de fecha, sin año como el diseño. */ export function formatShortDate(iso: string): string { return DATE.format(new Date(iso)) diff --git a/src/features/failed-events/content.ts b/src/features/failed-events/content.ts index bcda153..53926d1 100644 --- a/src/features/failed-events/content.ts +++ b/src/features/failed-events/content.ts @@ -1,15 +1,10 @@ import type { StatusVariant } from 'shared/components' +import { formatInteger } from 'shared/utils' import type { FailedEventDirection, FailedEventStatus } from './types' // Copy centralizado de la feature — sin literales sueltos en el JSX. -const numberFormat = new Intl.NumberFormat('es-AR') - -export function formatCount(value: number): string { - return numberFormat.format(value) -} - /** * Nombre legible de cada tipo de evento (`Webhooks::ReplayRegistry`). Un tipo * que no está acá se muestra tal cual: es mejor un nombre técnico que @@ -102,6 +97,6 @@ export const failedEventsCopy = { next: 'Página siguiente', page: (page: number) => `Ir a la página ${page}`, summary: (from: number, to: number, total: number) => - `Mostrando ${from} a ${to} de ${formatCount(total)} ${total === 1 ? 'evento' : 'eventos'}`, + `Mostrando ${from} a ${to} de ${formatInteger(total)} ${total === 1 ? 'evento' : 'eventos'}`, }, } diff --git a/src/features/failed-events/pages/FailedEventsPage.tsx b/src/features/failed-events/pages/FailedEventsPage.tsx index 9f786af..0f28708 100644 --- a/src/features/failed-events/pages/FailedEventsPage.tsx +++ b/src/features/failed-events/pages/FailedEventsPage.tsx @@ -11,15 +11,9 @@ import { } from 'shared/components' import type { DataTableColumn, DataTableTab } from 'shared/components' import { notify } from 'shared/store' -import { formatRelativeTime } from 'shared/utils' +import { formatRelativeTime, formatInteger } from 'shared/utils' -import { - DIRECTION_LABELS, - STATUS_LABELS, - STATUS_VARIANTS, - failedEventsCopy, - formatCount, -} from '../content' +import { DIRECTION_LABELS, STATUS_LABELS, STATUS_VARIANTS, failedEventsCopy } from '../content' import { NOT_REQUEUEABLE_STATUS, QUEUE_TABS, @@ -175,7 +169,7 @@ export function FailedEventsPage() { return { id, label: tabCopy[id], - ...(count === undefined ? {} : { count: formatCount(count) }), + ...(count === undefined ? {} : { count: formatInteger(count) }), } }) diff --git a/src/features/inventory/api.ts b/src/features/inventory/api.ts index 7cc129d..3bb892b 100644 --- a/src/features/inventory/api.ts +++ b/src/features/inventory/api.ts @@ -1,6 +1,7 @@ import { client } from 'shared/api/client' import type { + CatalogCounts, CreateProductPayload, LinkProductPayload, LinkProductResult, @@ -31,11 +32,13 @@ interface ApiWarehouse { name: string address: string zip_code: string + capacity?: number | null } interface ApiStock { id: number quantity: number + committed?: number warehouse_id: number warehouse: ApiWarehouse stock_status: StockStatus @@ -59,6 +62,8 @@ interface ApiProduct { name: string description: string | null category: string | null + packaging: string | null + technical_standard: string | null weight: number dimensions: string | null total_stock: number @@ -66,6 +71,10 @@ interface ApiProduct { in_transit_quantity: number in_transit_by_warehouse: { warehouse_id: number; name: string; quantity: number }[] updated_at: string + committed_quantity: number + on_hand_quantity: number + available_to_promise: number + committed_by_warehouse: { warehouse_id: number; name: string; quantity: number }[] stocks: ApiStock[] } @@ -75,7 +84,12 @@ interface ApiList { } function toWarehouse(warehouse: ApiWarehouse): Warehouse { - return { id: warehouse.id, name: warehouse.name, address: warehouse.address } + return { + id: warehouse.id, + name: warehouse.name, + address: warehouse.address, + capacity: warehouse.capacity ?? null, + } } // El ETag viene entrecomillado y puede traer el prefijo débil `W/`. Se guarda @@ -85,12 +99,22 @@ function readVersion(etag: unknown): string | null { } function toProduct(product: ApiProduct, version: string | null = null): Product { + // Lo comprometido llega aparte y por depósito: un depósito puede tener + // unidades vendidas sin despachar y ninguna fila de stock, si la venta se + // llevó la última. Se indexa por depósito para resolver cada fila sin + // recorrer la lista entera por cada una. + const committedByWarehouse = new Map( + (product.committed_by_warehouse ?? []).map((row) => [row.warehouse_id, row.quantity]), + ) + return { id: product.id, sku: product.sku, name: product.name, description: product.description, category: (product.category ?? null) as Product['category'], + packaging: product.packaging, + technicalStandard: product.technical_standard, weight: product.weight, dimensions: product.dimensions, totalStock: product.total_stock, @@ -101,11 +125,21 @@ function toProduct(product: ApiProduct, version: string | null = null): Product name: transit.name, quantity: transit.quantity, })), + committedByWarehouse: (product.committed_by_warehouse ?? []).map((row) => ({ + warehouseId: row.warehouse_id, + name: row.name, + quantity: row.quantity, + })), updatedAt: product.updated_at, + committed: product.committed_quantity, + onHand: product.on_hand_quantity, + availableToPromise: product.available_to_promise, + inTransit: product.in_transit_quantity, version, stocks: (product.stocks ?? []).map((stock) => ({ warehouseId: stock.warehouse_id, quantity: stock.quantity, + committed: committedByWarehouse.get(stock.warehouse_id) ?? 0, warehouse: toWarehouse(stock.warehouse), stockStatus: stock.stock_status, })), @@ -128,10 +162,12 @@ function toSummary(product: ApiProductSummary): ProductSummary { // Un filtro vacío no viaja: mandar `status=` o `search=` en blanco haría que el // backend filtre por cadena vacía y devuelva cero filas. -function toParams({ page, perPage, status, search, category }: ProductFilters) { +// `page` y `perPage` son opcionales acá: `/products/counts` no pagina, y +// mandarle una página sería decirle que cuente una porción (TESIS-163). +function toParams({ page, perPage, status, search, category }: Partial) { return { - page, - per_page: perPage, + ...(page === undefined ? {} : { page }), + ...(perPage === undefined ? {} : { per_page: perPage }), ...(status === undefined ? {} : { status }), ...(search === undefined || search === '' ? {} : { search }), ...(category === undefined ? {} : { category }), @@ -152,17 +188,21 @@ export async function fetchProductPage(filters: ProductFilters): Promise { - const { data } = await client.get>('/products', { - params: toParams({ page: 1, perPage: 1, status, search }), +export async function fetchProductCounts( + filters: Pick, +): Promise { + const { data } = await client.get<{ data: CatalogCounts }>('/products/counts', { + params: toParams({ search: filters.search, category: filters.category }), }) - return data.meta.total + return data.data } export async function fetchProduct(id: number): Promise { diff --git a/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx b/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx index de7d145..5fa838b 100644 --- a/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx +++ b/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx @@ -12,18 +12,31 @@ function product(overrides: Partial = {}): Product { sku: 'CAB-6-305', name: 'Cable UTP Cat6', description: 'Rollo de 305 metros', + category: null, + packaging: null, + technicalStandard: null, weight: 12.4, dimensions: '45x30x20', - category: null, + committed: 0, + onHand: 10, + availableToPromise: 10, + inTransit: 0, totalStock: 10, stockStatus: 'low', inTransitQuantity: 0, inTransitByWarehouse: [], + committedByWarehouse: [], stocks: [ { warehouseId: 1, quantity: 10, - warehouse: { id: 1, name: 'CD Ezeiza', address: 'Autopista Riccheri km 33' }, + committed: 0, + warehouse: { + id: 1, + name: 'CD Ezeiza', + address: 'Autopista Riccheri km 33', + capacity: null, + }, stockStatus: 'low', }, ], @@ -51,6 +64,47 @@ function renderModal(initial: Product) { } } +// TESIS-163: «Editar stock» abría el formulario entero del producto. El modal +// mantiene un solo guardado y cambia de alcance. +describe('EditProductModal · scope', () => { + function renderStockScope() { + renderWithTheme( + , + ) + } + + it('edits only the quantities when it is opened from «Editar stock»', () => { + renderStockScope() + + expect(screen.getByText('Asignación de stock')).toBeInTheDocument() + // El nombre del producto es lo que el formulario entero deja editar: con + // alcance de stock no está, y el valor tampoco aparece en ningún input. + expect(screen.queryByDisplayValue('Cable UTP Cat6')).not.toBeInTheDocument() + expect(screen.queryByText('Información básica')).not.toBeInTheDocument() + expect(screen.queryByText('Especificaciones técnicas')).not.toBeInTheDocument() + }) + + it('says in its title that it is editing stock', () => { + renderStockScope() + + expect(screen.getByText('Editar stock: Cable UTP Cat6')).toBeInTheDocument() + }) + + it('still edits everything with the default scope', () => { + renderModal(product()) + + expect(screen.getByDisplayValue('Cable UTP Cat6')).toBeInTheDocument() + expect(screen.getByText('Información básica')).toBeInTheDocument() + }) +}) + describe('EditProductModal', () => { it('fills the form with the values of the product it receives', () => { renderModal(product()) diff --git a/src/features/inventory/components/EditProductModal/EditProductModal.tsx b/src/features/inventory/components/EditProductModal/EditProductModal.tsx index 5bd68a2..9aa1700 100644 --- a/src/features/inventory/components/EditProductModal/EditProductModal.tsx +++ b/src/features/inventory/components/EditProductModal/EditProductModal.tsx @@ -60,6 +60,13 @@ function buildDefaults(product: Product): EditProductFormData { /** * Modal de edición de un producto: datos básicos, medidas y stock por depósito. * + * Con `scope="stock"` muestra sólo el bloque de depósitos. El botón «Editar + * stock» del detalle abría el formulario entero, que es lo que marcó la + * auditoría del 04/10: prometía una cosa y hacía otra. Es un alcance del mismo + * modal y no otro componente porque el guardado, el `If-Match` y el 412 + * (TESIS-101) son los mismos y duplicarlos sería tener dos versiones de la + * parte delicada. + * * Es presentacional: recibe el producto y los depósitos ya resueltos y entrega * en `onSubmit` el cuerpo listo para `PUT /api/v1/products/:id`. Quien lo monta * decide de dónde salen los datos y qué hacer con el resultado. @@ -73,7 +80,9 @@ export function EditProductModal({ onClose, submitting = false, conflict, + scope = 'product', }: EditProductModalProps) { + const onlyStock = scope === 'stock' const [menuAnchor, setMenuAnchor] = useState(null) const { @@ -125,7 +134,7 @@ export function EditProductModal({ return ( )} - - - - - - - - - - + {/* Con alcance `stock` el formulario es sólo el bloque de depósitos: + el botón que lo abre dice «Editar stock» y eso es lo que edita. */} + {onlyStock ? null : ( + + + + + + - ( - - )} - /> - - + + + - - - - - ( + + )} /> - + + + )} - - - + {onlyStock ? null : ( + + + + + + - - - + + + - - - - - + + + + + + + + + + )} ( - {formatUnits(product.totalStock)} + {formatInteger(product.totalStock)} {product.inTransitQuantity > 0 ? ( {cells.inTransit(product.inTransitQuantity)} diff --git a/src/features/inventory/components/MasterStockCard/MasterStockCard.tsx b/src/features/inventory/components/MasterStockCard/MasterStockCard.tsx index 36f0a69..5381beb 100644 --- a/src/features/inventory/components/MasterStockCard/MasterStockCard.tsx +++ b/src/features/inventory/components/MasterStockCard/MasterStockCard.tsx @@ -30,7 +30,6 @@ export function MasterStockCard({ totalLabel, caption, buckets, - footnote, onEditStock, }: MasterStockCardProps) { return ( @@ -75,12 +74,6 @@ export function MasterStockCard({ })} - {footnote === undefined ? null : ( - - {footnote} - - )} - + } + > + {activityContent.error} + + + ) : null} + + {/* Vacío no es un error: es una empresa donde todavía no pasó nada. */} + {activity.data?.length === 0 ? ( + + + {activityContent.empty} + + + ) : null} + + + {(activity.data ?? []).map((entry) => { + const to = activityPath(entry, paths) + + return ( + // Sin destino no es un botón: `ListItemButton` se anuncia como + // uno aunque se lo monte sobre un `div`, y una fila que informa y + // nada más no tiene que prometer una acción. Tampoco cierra el + // panel al tocarla, que era la otra mitad de la misma promesa. + + {ICONS[entry.type]} + + {activityLabel(entry)} + + {WHEN.format(new Date(entry.occurredAt))} + + + + ) + })} + + + ) +} diff --git a/src/shared/components/ActivityPanel/ActivityPanel.types.ts b/src/shared/components/ActivityPanel/ActivityPanel.types.ts new file mode 100644 index 0000000..eadba43 --- /dev/null +++ b/src/shared/components/ActivityPanel/ActivityPanel.types.ts @@ -0,0 +1,14 @@ +export interface ActivityPanelProps { + /** El botón que lo abrió. `null` lo mantiene cerrado. */ + anchorEl: HTMLElement | null + onClose: () => void + /** + * A dónde lleva cada fila. Llegan por props y no se importan del router: + * `shared/` no puede depender de `app/` (architecture.md §3.2). + */ + paths: { + order: (id: number) => string + /** Todavía no existe esa pantalla (TESIS-147): sin ella, la fila no enlaza. */ + failedEvents?: string + } +} diff --git a/src/shared/components/ActivityPanel/content.ts b/src/shared/components/ActivityPanel/content.ts new file mode 100644 index 0000000..879c06c --- /dev/null +++ b/src/shared/components/ActivityPanel/content.ts @@ -0,0 +1,24 @@ +// Copy del panel de actividad — sin literales sueltos en el JSX +// (docs/guidelines/component-structure.md §4). +export const activityContent = { + title: 'Actividad reciente', + loading: 'Buscando lo último que pasó…', + error: 'No pudimos cargar la actividad.', + retry: 'Reintentar', + /** Nada que mostrar no es un error: es una empresa que recién arranca. */ + empty: 'Todavía no pasó nada por acá.', + close: 'Cerrar', + /** El texto de cada hecho. La API manda el dato; la frase se arma acá. */ + orderCreated: { + manual: (customer: string) => `Nueva orden de ${customer}`, + channel: (customer: string, reference: string) => `Nueva venta de ${customer} · ${reference}`, + }, + shipmentDispatched: { + withCourier: (courier: string) => `Envío despachado con ${courier}`, + withoutCourier: 'Envío despachado', + }, + eventFailed: { + withIntegration: (integration: string) => `Falló un evento de ${integration}`, + withoutIntegration: 'Falló un evento de integración', + }, +} as const diff --git a/src/shared/components/ActivityPanel/entry.ts b/src/shared/components/ActivityPanel/entry.ts new file mode 100644 index 0000000..536fbca --- /dev/null +++ b/src/shared/components/ActivityPanel/entry.ts @@ -0,0 +1,56 @@ +import type { ActivityEntry, ActivityType } from '../../api/activity' + +import { activityContent } from './content' + +// Cómo se lee y a dónde lleva cada hecho del feed. Fuera del componente para +// poder probarlo sin montar el panel, mismo criterio que las reglas de las +// features (TESIS-163). + +/** + * La frase de una entrada. + * + * Se arma acá y no en el backend: la API manda el hecho y sus datos, y el texto + * es decisión de la pantalla. Mapa explícito por tipo y no un `switch` con + * default: si el backend suma un tipo, el compilador marca este archivo en vez + * de dejarlo sin texto. + */ +const LABELS: Record string> = { + order_created: (entry) => { + const customer = entry.customerName ?? '' + return entry.externalOrderId === null + ? activityContent.orderCreated.manual(customer) + : activityContent.orderCreated.channel(customer, entry.externalOrderId) + }, + shipment_dispatched: (entry) => + entry.courier === null + ? activityContent.shipmentDispatched.withoutCourier + : activityContent.shipmentDispatched.withCourier(entry.courier), + event_failed: (entry) => + entry.integration === null + ? activityContent.eventFailed.withoutIntegration + : activityContent.eventFailed.withIntegration(entry.integration), +} + +export function activityLabel(entry: ActivityEntry): string { + return LABELS[entry.type](entry) +} + +/** + * A dónde lleva la fila, o null si no hay a dónde. + * + * Una venta y un despacho llevan a la orden, porque el envío vive en su + * detalle. Las rutas llegan desde afuera: `shared/` no puede importar el router + * (architecture.md §3.2). + * + * `failedEvents` es opcional porque esa pantalla todavía no existe (la trae + * TESIS-147). Sin ella la fila se muestra igual y no enlaza, en vez de llevar a + * una ruta que el catch-all redirige al panel: el hecho importa aunque no haya + * a dónde ir a verlo. Cuando la pantalla entre, alcanza con pasar la ruta. + */ +export function activityPath( + entry: ActivityEntry, + paths: { order: (id: number) => string; failedEvents?: string }, +): string | null { + if (entry.type === 'event_failed') return paths.failedEvents ?? null + return entry.orderId === null ? null : paths.order(entry.orderId) +} diff --git a/src/shared/components/ActivityPanel/index.ts b/src/shared/components/ActivityPanel/index.ts new file mode 100644 index 0000000..e0783b0 --- /dev/null +++ b/src/shared/components/ActivityPanel/index.ts @@ -0,0 +1,2 @@ +export { ActivityPanel } from './ActivityPanel' +export type { ActivityPanelProps } from './ActivityPanel.types' diff --git a/src/shared/components/TopNavBar/NavSearch.tsx b/src/shared/components/TopNavBar/NavSearch.tsx deleted file mode 100644 index 38ce1f3..0000000 --- a/src/shared/components/TopNavBar/NavSearch.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import CloseIcon from '@mui/icons-material/Close' -import SearchIcon from '@mui/icons-material/Search' -import { IconButton, useMediaQuery, useTheme } from '@mui/material' -import { useState } from 'react' - -import { topNavContent } from './content' -import { SearchInput, SearchRoot } from './TopNavBar.styles' - -// Autocontenida: mantiene su propio valor y el toggle de colapso en mobile. -// Sin `onSearch` — la card solo pide capturar el input; la búsqueda global -// real se cablea cuando exista la feature correspondiente. -export function NavSearch() { - const theme = useTheme() - const isMobile = useMediaQuery(theme.breakpoints.down('sm')) - const [mobileOpen, setMobileOpen] = useState(false) - const [value, setValue] = useState('') - - if (isMobile && !mobileOpen) { - return ( - setMobileOpen(true)} - sx={{ color: 'text.secondary' }} - > - - - ) - } - - return ( - - - setValue(e.target.value)} - /> - {isMobile ? ( - { - setMobileOpen(false) - setValue('') - }} - > - - - ) : null} - - ) -} diff --git a/src/shared/components/TopNavBar/TopNavBar.styles.ts b/src/shared/components/TopNavBar/TopNavBar.styles.ts index 46e0e73..a64f8a5 100644 --- a/src/shared/components/TopNavBar/TopNavBar.styles.ts +++ b/src/shared/components/TopNavBar/TopNavBar.styles.ts @@ -1,4 +1,4 @@ -import { Box, InputBase } from '@mui/material' +import { Box } from '@mui/material' import { styled } from '@mui/material/styles' import type { Theme } from '@mui/material/styles' import { Link } from 'react-router-dom' @@ -33,38 +33,6 @@ export const OrganizationName = styled(Box)(({ theme }) => ({ [theme.breakpoints.down('sm')]: { display: 'none' }, })) -interface SearchRootProps { - expanded?: boolean -} - -const SEARCH_TRANSIENT_PROPS = new Set(['expanded']) - -export const SearchRoot = styled(Box, { - shouldForwardProp: (prop) => !SEARCH_TRANSIENT_PROPS.has(prop as string), -})(({ theme, expanded }) => ({ - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1), - height: 40, - paddingInline: theme.spacing(1.5), - borderRadius: 9999, - border: `1px solid ${theme.vars.palette.divider}`, - backgroundColor: theme.alpha(theme.vars.palette.text.primary, 0.04), - width: expanded ? '100%' : 260, - transition: theme.transitions.create(['border-color', 'box-shadow']), - '&:focus-within': { - borderColor: theme.vars.palette.primary.main, - boxShadow: `0 0 0 3px ${theme.alpha(theme.vars.palette.primary.main, 0.2)}`, - }, -})) - -export const SearchInput = styled(InputBase)(({ theme }) => ({ - flex: 1, - color: theme.vars.palette.text.primary, - ...theme.typography.bodyMd, - '& ::placeholder': { color: theme.vars.palette.text.secondary, opacity: 1 }, -})) - export const userMenuPaperSx = (theme: Theme) => ({ mt: 1, minWidth: 200, diff --git a/src/shared/components/TopNavBar/TopNavBar.tsx b/src/shared/components/TopNavBar/TopNavBar.tsx index a736cb6..3eab664 100644 --- a/src/shared/components/TopNavBar/TopNavBar.tsx +++ b/src/shared/components/TopNavBar/TopNavBar.tsx @@ -7,12 +7,11 @@ import { AppBar, Badge, Box, IconButton, Stack, Toolbar, Tooltip } from '@mui/ma import { Link } from 'react-router-dom' import { topNavContent } from './content' -import { NavSearch } from './NavSearch' import { BrandLink, OrganizationName } from './TopNavBar.styles' import type { TopNavBarProps } from './TopNavBar.types' import { UserMenu } from './UserMenu' -// Shell de navegación global (brand + búsqueda + acciones + usuario). Fixed, +// Shell de navegación global (brand + acciones + usuario). Fixed, // full-width y por encima del drawer: intrínseco al componente vía el tema // (MuiAppBar en app/theme/components/appBar.ts), no configurable por props. export function TopNavBar({ @@ -25,8 +24,8 @@ export function TopNavBar({ onNotificationsClick, settingsTo, user, - onProfileClick, onLogout, + children, }: TopNavBarProps) { return ( @@ -48,8 +47,6 @@ export function TopNavBar({ - - {onToggleTheme ? ( onNotificationsClick?.(event.currentTarget)} aria-label={topNavContent.notificationsAriaLabel} sx={{ color: 'text.secondary' }} > @@ -102,8 +99,9 @@ export function TopNavBar({ ) : null} - + + {children} ) } diff --git a/src/shared/components/TopNavBar/TopNavBar.types.ts b/src/shared/components/TopNavBar/TopNavBar.types.ts index 0953e68..66c9981 100644 --- a/src/shared/components/TopNavBar/TopNavBar.types.ts +++ b/src/shared/components/TopNavBar/TopNavBar.types.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react' + export type TopNavThemeMode = 'light' | 'dark' export interface TopNavUser { @@ -24,11 +26,21 @@ export interface TopNavBarProps { onToggleTheme?: () => void /** Contador del badge de notificaciones. Badge oculto si es 0/undefined. */ notificationsCount?: number - onNotificationsClick?: () => void + /** + * La campanita entrega el botón que la disparó: el panel de actividad se + * ancla a él (TESIS-163). Sin el elemento, quien escucha tendría que buscarlo + * en el DOM, que es justo lo que un `Popover` de MUI no necesita. + */ + onNotificationsClick?: (anchor: HTMLElement) => void /** Si se define, muestra el ícono de engranaje linkeando a esta ruta. */ settingsTo?: string /** Datos del usuario. Sin esto, el avatar cae a un ícono genérico. */ user?: TopNavUser - onProfileClick?: () => void onLogout?: () => void + /** + * Lo que cuelga de la barra, anclado a ella: hoy el panel de actividad. Va + * como nodo y no como una prop por caso, porque `shared/components` no puede + * importar una feature y el contenido lo decide quien la monta. + */ + children?: ReactNode } diff --git a/src/shared/components/TopNavBar/UserMenu.tsx b/src/shared/components/TopNavBar/UserMenu.tsx index c69ad5f..6156591 100644 --- a/src/shared/components/TopNavBar/UserMenu.tsx +++ b/src/shared/components/TopNavBar/UserMenu.tsx @@ -1,6 +1,5 @@ import LogoutIcon from '@mui/icons-material/Logout' import PersonIcon from '@mui/icons-material/Person' -import PersonOutlineIcon from '@mui/icons-material/PersonOutline' import { Avatar, Box, @@ -28,13 +27,15 @@ function getInitials(name: string) { interface UserMenuProps { user?: TopNavUser - onProfileClick?: () => void onLogout?: () => void } // Avatar (imagen del usuario, con fallback nativo de MUI a los children si -// falla la carga) + menú desplegable con "Mi perfil" / "Cerrar sesión". -export function UserMenu({ user, onProfileClick, onLogout }: UserMenuProps) { +// falla la carga) + menú desplegable con quién tiene la sesión y «Cerrar +// sesión». Tenía además un «Mi perfil» que no llevaba a ningún lado —el header +// nunca pasó su handler y no hay pantalla de perfil—: se sacó en TESIS-163, en +// vez de dejar una acción que no hace nada. +export function UserMenu({ user, onLogout }: UserMenuProps) { const [anchorEl, setAnchorEl] = useState(null) const open = Boolean(anchorEl) const menuId = useId() @@ -84,13 +85,6 @@ export function UserMenu({ user, onProfileClick, onLogout }: UserMenuProps) { )} - - - - - {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.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 f5e7530..148412e 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) @@ -112,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, }), }, ), diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index c51ddd6..e35e958 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -23,3 +23,4 @@ export function isNonEmpty(value: T | null | undefined): value is T { } export { formatRelativeTime } from './relativeTime' +export { formatDecimal, formatInteger, formatMoney, formatRoundMoney } from './number' diff --git a/src/shared/utils/number.test.ts b/src/shared/utils/number.test.ts new file mode 100644 index 0000000..7a6e3a1 --- /dev/null +++ b/src/shared/utils/number.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' + +import { formatDecimal, formatInteger, formatMoney, formatRoundMoney } from './number' + +// Estos ejemplos venían repartidos en `inventory/utils/format.test.ts`, +// `orders/utils/format.test.ts` y `reports/utils/format.test.ts`, uno por cada +// copia del mismo formateador. Al quedar una sola implementación (TESIS-166), +// quedan en un solo lugar y valen para todas las pantallas. + +describe('formatInteger', () => { + it('uses a thousands separator, as the design writes it', () => { + expect(formatInteger(4280)).toBe('4.280') + }) + + it('has no separator below one thousand', () => { + expect(formatInteger(280)).toBe('280') + }) + + it('still formats a zero, which is a number like any other', () => { + expect(formatInteger(0)).toBe('0') + }) + + // Lo que la API manda es lo que la pantalla imprime, y si una versión vieja + // no manda un campo que la pantalla ya lee, `Intl` escribe `NaN`. La guarda + // existía en una sola de las copias; ahora la heredan todas. + it('says there is no data instead of printing NaN', () => { + expect(formatInteger(undefined as unknown as number)).toBe('—') + expect(formatInteger(Number.NaN)).toBe('—') + expect(formatInteger(Number.POSITIVE_INFINITY)).toBe('—') + }) +}) + +describe('formatDecimal', () => { + it('formats with a comma decimal separator', () => { + expect(formatDecimal(1.45)).toBe('1,45') + }) + + it('pads to two decimals', () => { + expect(formatDecimal(2)).toBe('2,00') + }) + + it('says there is no data instead of printing NaN', () => { + expect(formatDecimal(Number.NaN)).toBe('—') + }) +}) + +describe('formatMoney', () => { + it('groups thousands with a dot', () => { + expect(formatMoney(1478300)).toContain('1.478.300') + }) + + /** + * El diseño muestra los importes sin decimales, pero donde se usa es plata + * facturada: redondear en pantalla muestra un número que no es el de la + * orden. Este ejemplo fija la decisión de apartarse del mock. + */ + it('keeps the cents instead of rounding them away', () => { + expect(formatMoney(1478300.49)).toContain('1.478.300,49') + }) + + it('always shows two decimals, even on a round amount', () => { + expect(formatMoney(1000)).toContain('1.000,00') + }) + + it('formats zero without falling back to an empty cell', () => { + expect(formatMoney(0)).toContain('0,00') + }) + + it('says there is no data instead of printing NaN', () => { + expect(formatMoney(Number.NaN)).toBe('—') + }) +}) + +describe('formatRoundMoney', () => { + // El costo del envío se lee de un vistazo en una tabla: el centavo no cambia + // ninguna decisión y la columna gana en lectura. + it('drops the cents', () => { + expect(formatRoundMoney(58300)).toContain('58.300') + expect(formatRoundMoney(58300)).not.toContain(',') + }) + + it('rounds instead of truncating', () => { + expect(formatRoundMoney(58300.6)).toContain('58.301') + }) + + it('says there is no data instead of printing NaN', () => { + expect(formatRoundMoney(Number.NaN)).toBe('—') + }) +}) diff --git a/src/shared/utils/number.ts b/src/shared/utils/number.ts new file mode 100644 index 0000000..268017c --- /dev/null +++ b/src/shared/utils/number.ts @@ -0,0 +1,77 @@ +// El único lugar donde la app decide cómo se escribe un número. +// +// Locale fijo `es-AR`, mismo criterio que `formatDate`: el producto está entero +// en español (ver el vocabulario en `docs/design/README.md`) y todavía no hay +// i18n. El diseño escribe los enteros con punto de miles («4.280») y los +// decimales con coma («1,45 kg»). +// +// Hasta TESIS-166 cada feature tenía su copia: `formatUnits` en inventario y +// depósitos, `formatCount` en órdenes, envíos y eventos fallidos, y tres +// `Intl.NumberFormat` sueltos más. Eran el mismo formateador con distinto +// nombre, y sólo una de las copias se protegía de un valor no finito. + +/** La marca de «sin dato» del DS, la misma que usa el resto del producto. */ +const UNKNOWN_VALUE = '—' + +const INTEGER = new Intl.NumberFormat('es-AR', { maximumFractionDigits: 0 }) + +const DECIMAL = new Intl.NumberFormat('es-AR', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}) + +const MONEY = new Intl.NumberFormat('es-AR', { + style: 'currency', + currency: 'ARS', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}) + +const ROUND_MONEY = new Intl.NumberFormat('es-AR', { + style: 'currency', + currency: 'ARS', + maximumFractionDigits: 0, +}) + +/** + * Entero con separador de miles: `4280` → `"4.280"`. + * + * Un valor que no es un número finito sale como «—» y no como `NaN`. + * TypeScript dice que no puede pasar, pero el dato viene de la API: si una + * versión más vieja no manda un campo que esta pantalla ya lee —un front + * desplegado antes que su backend—, `Intl` imprime `NaN` en la cara del + * operador. «—» dice lo mismo que diría cualquier otro dato que falta. + * + * La guarda estaba en una sola de las copias que este módulo reemplaza + * (`inventory/utils/format.ts`, de una review de TESIS-163). Al unificar, la + * heredan todas. + */ +export function formatInteger(value: number): string { + return Number.isFinite(value) ? INTEGER.format(value) : UNKNOWN_VALUE +} + +/** Dos decimales siempre: `1.45` → `"1,45"`. Un peso, una medida. */ +export function formatDecimal(value: number): string { + return Number.isFinite(value) ? DECIMAL.format(value) : UNKNOWN_VALUE +} + +/** + * Importe con centavos: `1478300.49` → `"$ 1.478.300,49"`. + * + * Con dos decimales siempre, aunque el diseño los omita: donde se usa es plata + * facturada, y redondear $ 1.478.300,49 a $ 1.478.300 en pantalla es mostrar + * un número que no es el de la orden. + */ +export function formatMoney(amount: number): string { + return Number.isFinite(amount) ? MONEY.format(amount) : UNKNOWN_VALUE +} + +/** + * Importe sin centavos: `58300` → `"$ 58.300"`. + * + * Para los costos que se leen de un vistazo en una tabla —el del envío— donde + * el centavo no cambia ninguna decisión y la columna gana en lectura. + */ +export function formatRoundMoney(amount: number): string { + return Number.isFinite(amount) ? ROUND_MONEY.format(amount) : UNKNOWN_VALUE +}