From 1e11040988686c5294c6af2e4254a561ca0627d2 Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 2 Oct 2026 01:19:16 -0300 Subject: [PATCH] feat: [TESIS-999008] show the real numbers of the company in the reports screen The reports screen rendered a fixed sample dataset with a "sample data" badge, because there was no aggregates endpoint. It now reads GET /reports/overview: revenue and dispatched units with their trend, the daily or weekly curve, and how many shipments of each carrier were delivered. What the model cannot compute comes as null and is shown without value, saying why: on-time rate needs a committed date that shipments do not keep, and there is no anomaly entity, which is not the same as zero anomalies. The carrier column stops painting rates in semantic tones, since a shipment still on its way is not bad service. Co-Authored-By: Claude Opus 5.5 --- docs/guidelines/architecture.md | 26 ++-- src/features/reports/api.test.ts | 68 ++++++++++ src/features/reports/api.ts | 117 +++++++++++++++--- .../AnomaliesTable/AnomaliesTable.tsx | 4 +- .../AnomaliesTable/AnomaliesTable.types.ts | 3 +- .../DispatchCurveCard/DispatchCurveCard.tsx | 4 +- .../DispatchCurveCard.types.ts | 4 +- .../ReportMetrics/ReportMetrics.tsx | 50 +++++--- .../ServiceLevelCard.test.tsx | 45 ++++--- .../ServiceLevelCard/ServiceLevelCard.tsx | 69 +++++------ .../ServiceLevelCard.types.ts | 4 +- src/features/reports/content.ts | 22 +++- src/features/reports/index.ts | 2 +- .../reports/pages/ReportsPage.test.tsx | 28 ++++- src/features/reports/pages/ReportsPage.tsx | 21 ++-- src/features/reports/sampleData.ts | 19 +-- src/features/reports/types.ts | 38 ++++-- src/features/reports/utils/status.test.ts | 20 +-- src/features/reports/utils/status.ts | 23 +--- 19 files changed, 371 insertions(+), 196 deletions(-) create mode 100644 src/features/reports/api.test.ts diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 02c5c52..b92c3da 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -315,7 +315,7 @@ features/[nombre]/ | `home` | Landing de la app (`/`) | | `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` | | `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío, desde el que se despacha un envío que quedó pendiente (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los tres pasos del alta manual (`/orders/new`: cliente y productos, con el buscador filtrando contra el `search` del backend; `/orders/new/shipping`: origen y destino; `/orders/new/carrier`: cotización y confirmación) | -| `reports` | Reportes — analítica de la operación (`/reports`, S14): métricas del período, curva de despacho, nivel de servicio por operador y anomalías recientes. **Sin endpoint de agregados todavía**: la pantalla muestra el dataset de muestra del diseño y lo dice junto al título (ver `api.ts`) | +| `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é | **`orders` — piezas y por qué:** @@ -348,18 +348,18 @@ features/[nombre]/ **`reports` — piezas y por qué:** -| Archivo | Rol | -| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `api.ts` | Frontera con la API de reportes. El endpoint de agregados **no existe** (seis de los siete bloques de S14 no tienen dominio detrás, y los listados no filtran por fecha), así que hoy resuelve `sampleData.ts`. Es el único archivo que cambia cuando Rails lo exponga; `REPORTS_DATA_IS_SAMPLE` enciende el distintivo de la pantalla | -| `types.ts` | El contrato que ese endpoint tendrá que cumplir: `ReportsOverview` con las cuatro métricas (`TrendedValue`, con `trend: null` cuando no hay período anterior), la curva con sus dos series, el nivel de servicio por operador y las anomalías | -| `hooks/useReportsOverview.ts` | Una sola consulta por período y no una por bloque: el endpoint va a calcular todo sobre la misma ventana, y pedirlo junto garantiza que tarjetas, curva y tabla hablen del mismo período | -| `utils/chart.ts` | Geometría de la curva: techo «redondo» del eje (1, 2, 4 o 10 por magnitud, para que los ticks salgan enteros), posición de cada punto y suavizado Catmull-Rom → Bézier. Puras, para probarlas sin montar un SVG | -| `utils/format.ts` | `k` y `MM` en vez de la notación compacta de `Intl`: son las abreviaturas del diseño y las que usa la operación para hablar de millones de pesos | -| `utils/status.ts` | Umbrales provisorios del nivel de servicio (98 / 95 / 90) que reproducen los tonos del diseño hasta que el dominio modele un acuerdo de nivel de servicio; y el badge y énfasis de fila de cada estado de anomalía | -| `components/DispatchCurveCard/` | La curva sobre un SVG propio: no hay librería de gráficos en el stack y un solo gráfico no la justifica. El segmentado «Órdenes / Facturación» (`ToggleButtonGroup`, tematizado en `app/theme/components/toggleButton.ts`) es estado de la tarjeta | -| `components/ServiceLevelCard/` | Una fila por operador con `ProgressIndicator` de canal neutro, así las barras se comparan entre sí. Renderiza `
    ` porque el orden es un ranking | -| `components/AnomaliesTable/` | Sobre `DataTable`. El incidente no se pinta como enlace aunque el diseño lo haga: no hay pantalla de incidente a la que llevar. «Ver bitácora completa» queda visible y apagada, como el remito del detalle de orden | -| `components/ReportsHeader/` | Título, distintivo de «Datos de muestra», selector de período (`Menu`) y la exportación apagada: además de no tener endpoint, la decisión del proyecto es dejar la exportación para el final, cuando el modelo de datos esté completo | +| Archivo | Rol | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `api.ts` | Frontera con `GET /reports/overview`. Traduce las fechas de la curva a rótulos (día de la semana en 7 días, día/mes en los más largos, en UTC para no correr el día) y deja en `null` lo que la API no calcula. `sampleData.ts` queda como fixture de las pruebas | +| `types.ts` | `ReportsOverview`: las cuatro métricas (`TrendedValue`, con `trend: null` sin período anterior; cumplimiento y anomalías pueden ser `null`), la curva con su granularidad, las entregas por operador y las anomalías (`null` = el sistema no las registra, que no es lo mismo que ninguna) | +| `hooks/useReportsOverview.ts` | Una sola consulta por período y no una por bloque: el endpoint va a calcular todo sobre la misma ventana, y pedirlo junto garantiza que tarjetas, curva y tabla hablen del mismo período | +| `utils/chart.ts` | Geometría de la curva: techo «redondo» del eje (1, 2, 4 o 10 por magnitud, para que los ticks salgan enteros), posición de cada punto y suavizado Catmull-Rom → Bézier. Puras, para probarlas sin montar un SVG | +| `utils/format.ts` | `k` y `MM` en vez de la notación compacta de `Intl`: son las abreviaturas del diseño y las que usa la operación para hablar de millones de pesos | +| `utils/status.ts` | El badge y el énfasis de fila de cada estado de anomalía | +| `components/DispatchCurveCard/` | La curva sobre un SVG propio: no hay librería de gráficos en el stack y un solo gráfico no la justifica. El segmentado «Órdenes / Facturación» (`ToggleButtonGroup`, tematizado en `app/theme/components/toggleButton.ts`) es estado de la tarjeta | +| `components/ServiceLevelCard/` | «Entregas por operador»: despachados en el período y cuántos ya llegaron, en barras de un solo tono. Sin fecha comprometida no hay incumplimiento que pintar: un envío en camino no es mal servicio. Renderiza `
      ` porque la API los ordena por volumen | +| `components/AnomaliesTable/` | Sobre `DataTable`. El incidente no se pinta como enlace aunque el diseño lo haga: no hay pantalla de incidente a la que llevar. «Ver bitácora completa» queda visible y apagada, como el remito del detalle de orden | +| `components/ReportsHeader/` | Título, distintivo de «Datos de muestra», selector de período (`Menu`) y la exportación apagada: además de no tener endpoint, la decisión del proyecto es dejar la exportación para el final, cuando el modelo de datos esté completo | **`inventory` — piezas y por qué:** diff --git a/src/features/reports/api.test.ts b/src/features/reports/api.test.ts new file mode 100644 index 0000000..595e649 --- /dev/null +++ b/src/features/reports/api.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' + +import { curveLabel, toOverview } from './api' + +const API_OVERVIEW = { + period: '7d' as const, + from: '2026-09-26T00:00:00-03:00', + to: '2026-10-02T01:30:00-03:00', + granularity: 'day' as const, + kpis: { + orders: { value: 3, trend: null }, + revenue: { value: 4500, trend: 12.5 }, + dispatched_units: { value: 9, trend: -10 }, + on_time_delivery_rate: null, + active_anomalies: null, + }, + curve: [ + { date: '2026-09-26', orders: 1, revenue: 1500 }, + { date: '2026-09-27', orders: 0, revenue: 0 }, + ], + carriers: [{ company_integration_id: 3, name: 'Andreani', dispatched: 4, delivered: 1 }], +} + +describe('curveLabel', () => { + it('names the weekday in a one-week report', () => { + expect(curveLabel('2026-09-28', '7d')).toBe('lun') + }) + + it('uses day and month in the longer ones, where weekdays would repeat', () => { + expect(curveLabel('2026-09-28', '30d')).toBe('28/09') + }) + + // La fecha es un día calendario: interpretarla en la zona del navegador lo + // correría al día anterior al oeste de UTC. + it('never moves the day with the time zone of the browser', () => { + expect(curveLabel('2026-10-01', '90d')).toBe('01/10') + }) +}) + +describe('toOverview', () => { + it('keeps the trend the API computed, and its absence', () => { + const { kpis } = toOverview(API_OVERVIEW) + + expect(kpis.revenue).toEqual({ value: 4500, trend: 12.5 }) + expect(kpis.dispatchedUnits).toEqual({ value: 9, trend: -10 }) + }) + + it('leaves without value what the model cannot compute', () => { + const overview = toOverview(API_OVERVIEW) + + expect(overview.kpis.onTimeDeliveryRate).toBeNull() + expect(overview.kpis.activeAnomalies).toBeNull() + expect(overview.anomalies).toBeNull() + }) + + it('turns the dates of the curve into axis labels, empty days included', () => { + expect(toOverview(API_OVERVIEW).curve).toEqual([ + { label: 'sáb', orders: 1, revenue: 1500 }, + { label: 'dom', orders: 0, revenue: 0 }, + ]) + }) + + it('translates the carriers', () => { + expect(toOverview(API_OVERVIEW).carriers).toEqual([ + { carrier: 'Andreani', dispatched: 4, delivered: 1 }, + ]) + }) +}) diff --git a/src/features/reports/api.ts b/src/features/reports/api.ts index 05e703b..c3622c6 100644 --- a/src/features/reports/api.ts +++ b/src/features/reports/api.ts @@ -1,27 +1,104 @@ -import { SAMPLE_OVERVIEW } from './sampleData' -import type { ReportPeriod, ReportsOverview } from './types' +import { client } from 'shared/api/client' -// Frontera con la API de reportes. -// -// El endpoint de agregados NO EXISTE todavía. De los siete bloques de S14, seis -// no tienen dominio detrás (comentarios de TESIS-64, 2026-08-29): `orders` no -// guarda facturación por período, `shipments` no tiene fecha comprometida con -// qué medir el cumplimiento, y no hay anomalías, regiones ni nivel de servicio -// por operador en el modelo. Lo que hay son los listados paginados, y ninguno -// filtra por fecha, así que ni el volumen puede acotarse al período elegido. +import type { + CurveGranularity, + CurvePoint, + ReportPeriod, + ReportsOverview, + TrendedValue, +} from './types' + +// Frontera con `GET /api/v1/reports/overview` (TESIS-999007). Ningún +// componente ve el snake_case ni las fechas crudas de la curva. // -// Hasta que exista, esta función resuelve el dataset de muestra del diseño. Es -// el único lugar que cambia cuando la API lo exponga: recibe el período, -// devuelve el `ReportsOverview` del dominio, y la pantalla no se entera. +// La API calcula sólo lo que el modelo permite: el cumplimiento de plazo y las +// anomalías llegan como `null` explícito (no hay fecha comprometida en los +// envíos ni una entidad de anomalía). La pantalla los muestra sin dato. + +interface ApiTrended { + value: number + trend: number | null +} + +interface ApiOverview { + period: ReportPeriod + from: string + to: string + granularity: CurveGranularity + kpis: { + orders: ApiTrended + revenue: ApiTrended + dispatched_units: ApiTrended + on_time_delivery_rate: ApiTrended | null + active_anomalies: { count: number; critical: boolean } | null + } + curve: { date: string; orders: number; revenue: number }[] + carriers: { + company_integration_id: number + name: string + dispatched: number + delivered: number + }[] +} + +// Las fechas de la curva son días calendario (`2026-09-26`) ya cortados en +// hora de Argentina por la API. Se formatean en UTC a propósito: interpretarlas +// en la zona del navegador correría el día en cualquier huso al oeste de UTC. +const WEEKDAY = new Intl.DateTimeFormat('es-AR', { weekday: 'short', timeZone: 'UTC' }) /** - * Mientras sea `true`, la pantalla lo dice junto al título. Se borra con - * `sampleData.ts` el día que `fetchReportsOverview` hable con Rails. + * Rótulo del eje X: el día de la semana en una semana («lun»), y día/mes en + * los períodos más largos («26/09»), donde el día de la semana se repetiría. + * En la curva semanal es el lunes que abre cada semana. */ -export const REPORTS_DATA_IS_SAMPLE = true +export function curveLabel(date: string, period: ReportPeriod): string { + if (period !== '7d') { + // Armado a mano y no con `Intl`: según la versión de ICU, `es-AR` con + // `2-digit` da «28/09» o «28/9», y el rótulo es la identidad del punto. + const [, month, day] = date.split('-') + return `${day}/${month}` + } + + return WEEKDAY.format(new Date(`${date}T00:00:00Z`)).replace('.', '') +} + +function toTrended(value: ApiTrended): TrendedValue { + return { value: value.value, trend: value.trend } +} + +function toCurve(points: ApiOverview['curve'], period: ReportPeriod): CurvePoint[] { + return points.map((point) => ({ + label: curveLabel(point.date, period), + orders: point.orders, + revenue: point.revenue, + })) +} + +export function toOverview(data: ApiOverview): ReportsOverview { + const { kpis } = data + + return { + kpis: { + dispatchedUnits: toTrended(kpis.dispatched_units), + revenue: toTrended(kpis.revenue), + onTimeDeliveryRate: + kpis.on_time_delivery_rate === null ? null : toTrended(kpis.on_time_delivery_rate), + activeAnomalies: kpis.active_anomalies, + }, + granularity: data.granularity, + curve: toCurve(data.curve, data.period), + carriers: data.carriers.map((carrier) => ({ + carrier: carrier.name, + dispatched: carrier.dispatched, + delivered: carrier.delivered, + })), + // La API no tiene anomalías: no es "ninguna en el período", es "no existen". + anomalies: null, + } +} + +export async function fetchReportsOverview(period: ReportPeriod): Promise { + const { data } = await client.get('/reports/overview', { params: { period } }) -// `_period` queda declarado a propósito: es parte del contrato del endpoint y -// de la query key, aunque la muestra no lo mire. -export function fetchReportsOverview(_period: ReportPeriod): Promise { - return Promise.resolve(SAMPLE_OVERVIEW) + return toOverview(data) } diff --git a/src/features/reports/components/AnomaliesTable/AnomaliesTable.tsx b/src/features/reports/components/AnomaliesTable/AnomaliesTable.tsx index 2cdba5b..05ff034 100644 --- a/src/features/reports/components/AnomaliesTable/AnomaliesTable.tsx +++ b/src/features/reports/components/AnomaliesTable/AnomaliesTable.tsx @@ -74,7 +74,7 @@ export function AnomaliesTable({ anomalies }: AnomaliesTableProps) { return ( anomaly.id} label={copy.tableLabel} title={copy.title} @@ -90,7 +90,7 @@ export function AnomaliesTable({ anomalies }: AnomaliesTableProps) { } - emptyMessage={copy.empty} + emptyMessage={anomalies === null ? copy.unavailable : copy.empty} /> ) } diff --git a/src/features/reports/components/AnomaliesTable/AnomaliesTable.types.ts b/src/features/reports/components/AnomaliesTable/AnomaliesTable.types.ts index 6ccb9eb..8424b99 100644 --- a/src/features/reports/components/AnomaliesTable/AnomaliesTable.types.ts +++ b/src/features/reports/components/AnomaliesTable/AnomaliesTable.types.ts @@ -1,5 +1,6 @@ import type { RegionalAnomaly } from '../../types' export interface AnomaliesTableProps { - anomalies: RegionalAnomaly[] + /** `null` cuando el sistema no registra anomalías: la tabla lo dice, vacía. */ + anomalies: RegionalAnomaly[] | null } diff --git a/src/features/reports/components/DispatchCurveCard/DispatchCurveCard.tsx b/src/features/reports/components/DispatchCurveCard/DispatchCurveCard.tsx index f4ff668..b30a7d6 100644 --- a/src/features/reports/components/DispatchCurveCard/DispatchCurveCard.tsx +++ b/src/features/reports/components/DispatchCurveCard/DispatchCurveCard.tsx @@ -24,7 +24,7 @@ const AXIS_FORMAT: Record string> = { * para alternar entre órdenes y facturación. Los puntos traen las dos series; * cuál se dibuja es estado de la tarjeta y de nadie más. */ -export function DispatchCurveCard({ points }: DispatchCurveCardProps) { +export function DispatchCurveCard({ points, granularity = 'day' }: DispatchCurveCardProps) { const [metric, setMetric] = useState('orders') // El segmentado es exclusivo: soltar la opción activa devuelve `null`, y una @@ -54,7 +54,7 @@ export function DispatchCurveCard({ points }: DispatchCurveCardProps) { values={points.map((point) => point[metric])} labels={points.map((point) => point.label)} formatValue={AXIS_FORMAT[metric]} - ariaLabel={copy.chartLabel(copy.metrics[metric])} + ariaLabel={copy.chartLabel(copy.metrics[metric], granularity)} /> ) diff --git a/src/features/reports/components/DispatchCurveCard/DispatchCurveCard.types.ts b/src/features/reports/components/DispatchCurveCard/DispatchCurveCard.types.ts index 4da2fea..a5bb5bb 100644 --- a/src/features/reports/components/DispatchCurveCard/DispatchCurveCard.types.ts +++ b/src/features/reports/components/DispatchCurveCard/DispatchCurveCard.types.ts @@ -1,7 +1,9 @@ -import type { CurvePoint } from '../../types' +import type { CurveGranularity, CurvePoint } from '../../types' export interface DispatchCurveCardProps { points: CurvePoint[] + /** Si cada punto es un día o una semana: lo dice la descripción del gráfico. */ + granularity?: CurveGranularity } export interface LineChartProps { diff --git a/src/features/reports/components/ReportMetrics/ReportMetrics.tsx b/src/features/reports/components/ReportMetrics/ReportMetrics.tsx index ab407be..94a2f51 100644 --- a/src/features/reports/components/ReportMetrics/ReportMetrics.tsx +++ b/src/features/reports/components/ReportMetrics/ReportMetrics.tsx @@ -50,23 +50,43 @@ export function ReportMetrics({ kpis }: ReportMetricsProps) { trend={trendOf(revenue)} icon={} /> - } - /> + {/* Sin fecha comprometida no hay cumplimiento que medir: la tarjeta del + diseño queda, sin valor y diciendo por qué, en vez de un número. */} + {onTimeDeliveryRate === null ? ( + } + /> + ) : ( + } + /> + )} {/* Con una anomalía crítica la tarjeta entera pasa a alerta: borde, ícono y chip, como el `critical` del MetricCard del diseño. */} - } - icon={} - /> + {activeAnomalies === null ? ( + } + /> + ) : ( + } + icon={} + /> + )} ) } diff --git a/src/features/reports/components/ServiceLevelCard/ServiceLevelCard.test.tsx b/src/features/reports/components/ServiceLevelCard/ServiceLevelCard.test.tsx index 9fc2232..1900a0c 100644 --- a/src/features/reports/components/ServiceLevelCard/ServiceLevelCard.test.tsx +++ b/src/features/reports/components/ServiceLevelCard/ServiceLevelCard.test.tsx @@ -7,33 +7,46 @@ import { SAMPLE_OVERVIEW } from '../../sampleData' import { ServiceLevelCard } from './ServiceLevelCard' describe('ServiceLevelCard', () => { - it('lists the four carriers of the product with their rate', () => { - renderWithTheme() + it('lists each carrier with how many of its shipments were delivered', () => { + renderWithTheme() expect(screen.getByText('Andreani')).toBeInTheDocument() - expect(screen.getByText('99,4%')).toBeInTheDocument() - expect(screen.getByText('OCASA')).toBeInTheDocument() - expect(screen.getByText('88,5%')).toBeInTheDocument() + expect(screen.getByText('171 de 180')).toBeInTheDocument() + expect(screen.getByText('95%')).toBeInTheDocument() }) // El rótulo visible vive fuera de la barra: cada una tiene que anunciar de // qué operador es y cuánto vale. it('exposes each bar as a named progressbar with its value', () => { - renderWithTheme() + renderWithTheme() - const bar = screen.getByRole('progressbar', { name: 'Entregas en plazo de Moova' }) - expect(bar).toHaveAttribute('aria-valuenow', '97') + const bar = screen.getByRole('progressbar', { name: 'Envíos entregados de Moova' }) + expect(bar).toHaveAttribute('aria-valuenow', '93') }) - it('keeps the order it receives: it is a ranking', () => { - renderWithTheme() + it('keeps the order it receives: the API ranks by volume', () => { + renderWithTheme() - const items = screen.getAllByRole('listitem') - expect(items.map((item) => item.textContent)).toEqual([ - 'Andreani99,4%', - 'Moova96,8%', - 'Correo Argentino94,2%', - 'OCASA88,5%', + const names = screen.getAllByRole('listitem').map((item) => item.textContent ?? '') + expect(names.map((text) => text.split(/\d/)[0])).toEqual([ + 'Andreani', + 'Moova', + 'Correo Argentino', ]) }) + + it('says so when nothing was dispatched in the period', () => { + renderWithTheme() + + expect(screen.getByText('No se despacharon envíos en el período.')).toBeInTheDocument() + }) + + // Sin despachos no hay tasa: el 0/0 no puede convertirse en NaN. + it('shows a zero rate for a carrier with no dispatched shipments', () => { + renderWithTheme( + , + ) + + expect(screen.getByText('0%')).toBeInTheDocument() + }) }) diff --git a/src/features/reports/components/ServiceLevelCard/ServiceLevelCard.tsx b/src/features/reports/components/ServiceLevelCard/ServiceLevelCard.tsx index 3c4b500..61a8217 100644 --- a/src/features/reports/components/ServiceLevelCard/ServiceLevelCard.tsx +++ b/src/features/reports/components/ServiceLevelCard/ServiceLevelCard.tsx @@ -1,14 +1,8 @@ -import CheckIcon from '@mui/icons-material/Check' -import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' -import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined' import { Typography } from '@mui/material' -import type { ReactNode } from 'react' import { ProgressIndicator } from 'shared/components' -import type { ProgressTone } from 'shared/components' import { reportsCopy } from '../../content' import { formatPercent } from '../../utils/format' -import { serviceLevelTone } from '../../utils/status' import { CardHeading, @@ -23,22 +17,21 @@ import type { ServiceLevelCardProps } from './ServiceLevelCard.types' const { serviceLevel: copy } = reportsCopy -// El ícono que acompaña al porcentaje, por tono (los del ProgressBar del DS). -const TONE_ICONS: Record = { - primary: , - success: , - info: , - neutral: , - warning: , - error: , +/** Entregados sobre despachados, 0-100. Sin despachos no hay tasa. */ +function deliveredRate({ dispatched, delivered }: { dispatched: number; delivered: number }) { + return dispatched === 0 ? 0 : (delivered / dispatched) * 100 } /** - * «Nivel de servicio» de S14: la tasa de entregas en plazo de cada operador - * logístico, como barra fina con su porcentaje. El tono lo decide la tasa (ver - * `serviceLevelTone`), no el operador. + * La columna lateral de S14, con lo que el modelo puede afirmar: cuántos de + * los envíos que despachó cada operador en el período ya llegaron. + * + * El diseño muestra acá las entregas **en plazo**, que necesitan una fecha + * comprometida que los envíos no guardan. Un envío despachado ayer que todavía + * viaja no es un incumplimiento, así que la barra va en un solo tono: el color + * semántico del diseño diría «mal servicio» de algo que sólo está en camino. */ -export function ServiceLevelCard({ levels }: ServiceLevelCardProps) { +export function ServiceLevelCard({ carriers }: ServiceLevelCardProps) { return ( @@ -50,34 +43,34 @@ export function ServiceLevelCard({ levels }: ServiceLevelCardProps) { - - {levels.map(({ carrier, onTimeRate }) => { - const tone = serviceLevelTone(onTimeRate) - - return ( - + {carriers.length === 0 ? ( + + {copy.empty} + + ) : ( + + {carriers.map((row) => ( + - {carrier} - - {TONE_ICONS[tone]} - {formatPercent(onTimeRate)} + {row.carrier} + + {copy.delivered(row.delivered, row.dispatched)} + + {formatPercent(deliveredRate(row))} + - {/* Canal neutro: el tono codifica el estado de cada operador y el - 100% de referencia es el mismo en todas las filas, así las - barras se comparan entre sí. El rótulo visible vive fuera de - la barra, por eso el nombre accesible va explícito. */} - ) - })} - + ))} + + )} ) } diff --git a/src/features/reports/components/ServiceLevelCard/ServiceLevelCard.types.ts b/src/features/reports/components/ServiceLevelCard/ServiceLevelCard.types.ts index 014c67f..bcf96a9 100644 --- a/src/features/reports/components/ServiceLevelCard/ServiceLevelCard.types.ts +++ b/src/features/reports/components/ServiceLevelCard/ServiceLevelCard.types.ts @@ -1,5 +1,5 @@ -import type { CarrierServiceLevel } from '../../types' +import type { CarrierDeliveries } from '../../types' export interface ServiceLevelCardProps { - levels: CarrierServiceLevel[] + carriers: CarrierDeliveries[] } diff --git a/src/features/reports/content.ts b/src/features/reports/content.ts index 415c581..4cc5e99 100644 --- a/src/features/reports/content.ts +++ b/src/features/reports/content.ts @@ -6,9 +6,9 @@ export const reportsCopy = { page: { title: 'Reportes', subtitle: 'Volumen, facturación y cumplimiento de la operación.', - /** Distintivo junto al título mientras la API no expone agregados. */ + /** Distintivo junto al título para un dataset que no sale de la API. */ sampleData: 'Datos de muestra', - sampleDataHint: 'La API todavía no expone agregados de reportes: los valores son ilustrativos.', + sampleDataHint: 'Los valores son ilustrativos: no salen de la operación de la empresa.', error: 'No pudimos cargar los reportes.', }, period: { @@ -32,6 +32,10 @@ export const reportsCopy = { onTimeDelivery: 'Cumplimiento de entregas', activeAnomalies: 'Anomalías activas', critical: 'Crítico', + /** Valor de una métrica que el modelo todavía no puede calcular. */ + unavailable: '—', + onTimeUnavailable: 'Requiere la fecha de entrega comprometida, que el sistema no registra.', + anomaliesUnavailable: 'El sistema todavía no registra anomalías.', }, curve: { title: 'Curva de despacho', @@ -42,12 +46,15 @@ export const reportsCopy = { revenue: 'Facturación', }, /** Descripción del gráfico para el lector de pantalla. */ - chartLabel: (metric: string) => `Curva de despacho: ${metric.toLowerCase()} por día`, + chartLabel: (metric: string, granularity: 'day' | 'week') => + `Curva de despacho: ${metric.toLowerCase()} por ${granularity === 'week' ? 'semana' : 'día'}`, }, serviceLevel: { - title: 'Nivel de servicio', - subtitle: 'Entregas en plazo por operador logístico.', - barLabel: (carrier: string) => `Entregas en plazo de ${carrier}`, + title: 'Entregas por operador', + subtitle: 'Envíos despachados en el período que ya llegaron a destino.', + delivered: (delivered: number, dispatched: number) => `${delivered} de ${dispatched}`, + barLabel: (carrier: string) => `Envíos entregados de ${carrier}`, + empty: 'No se despacharon envíos en el período.', }, anomalies: { title: 'Anomalías recientes', @@ -68,5 +75,8 @@ export const reportsCopy = { resolved: 'Resuelto', }, empty: 'No hay anomalías registradas en el período.', + /** El modelo no tiene anomalías: no es lo mismo que «ninguna en el período». */ + unavailable: + 'El sistema todavía no registra anomalías: esta tabla se completa cuando exista ese dato.', }, } as const diff --git a/src/features/reports/index.ts b/src/features/reports/index.ts index 65338da..161a625 100644 --- a/src/features/reports/index.ts +++ b/src/features/reports/index.ts @@ -2,7 +2,7 @@ export { ReportsPage } from './pages/ReportsPage' export { useReportsOverview } from './hooks/useReportsOverview' export type { AnomalyStatus, - CarrierServiceLevel, + CarrierDeliveries, CurveMetric, CurvePoint, RegionalAnomaly, diff --git a/src/features/reports/pages/ReportsPage.test.tsx b/src/features/reports/pages/ReportsPage.test.tsx index d5fe656..c12e3cf 100644 --- a/src/features/reports/pages/ReportsPage.test.tsx +++ b/src/features/reports/pages/ReportsPage.test.tsx @@ -59,17 +59,37 @@ describe('ReportsPage', () => { expect(screen.getByText('Cumplimiento de entregas')).toBeInTheDocument() expect(screen.getByText('Anomalías activas')).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Curva de despacho' })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'Nivel de servicio' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Entregas por operador' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Anomalías recientes' })).toBeInTheDocument() }) - // Mientras la API no exponga agregados, la pantalla lo dice. - it('flags the numbers as sample data', () => { + // Los números salen de la API: el distintivo de muestra ya no corresponde. + it('no longer flags the numbers as sample data', () => { useReportsOverviewMock.mockReturnValue(query({ data: SAMPLE_OVERVIEW })) renderWithTheme() - expect(screen.getByText('Datos de muestra')).toBeInTheDocument() + expect(screen.queryByText('Datos de muestra')).not.toBeInTheDocument() + }) + + // Lo que el modelo no puede calcular llega en `null`: la tarjeta queda y lo + // dice, en vez de un número inventado o un cero que se leería como dato. + it('shows without value and explains what the model cannot compute', () => { + useReportsOverviewMock.mockReturnValue( + query({ + data: { + ...SAMPLE_OVERVIEW, + kpis: { ...SAMPLE_OVERVIEW.kpis, onTimeDeliveryRate: null, activeAnomalies: null }, + anomalies: null, + }, + }), + ) + + renderWithTheme() + + expect(screen.getByText(/Requiere la fecha de entrega comprometida/)).toBeInTheDocument() + expect(screen.getByText('El sistema todavía no registra anomalías.')).toBeInTheDocument() + expect(screen.getByText(/esta tabla se completa cuando exista ese dato/)).toBeInTheDocument() }) it('asks for the last 30 days by default and refetches when the period changes', () => { diff --git a/src/features/reports/pages/ReportsPage.tsx b/src/features/reports/pages/ReportsPage.tsx index 7aa4abb..3ac7ab8 100644 --- a/src/features/reports/pages/ReportsPage.tsx +++ b/src/features/reports/pages/ReportsPage.tsx @@ -2,7 +2,6 @@ import { Box, Stack } from '@mui/material' import { useState } from 'react' import { ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components' -import { REPORTS_DATA_IS_SAMPLE } from '../api' import { AnomaliesTable } from '../components/AnomaliesTable' import { DispatchCurveCard } from '../components/DispatchCurveCard' import { ReportMetrics } from '../components/ReportMetrics' @@ -22,9 +21,9 @@ const SIDE_COLUMN_WIDTH = 320 * Reportes — analítica de la operación (S14, TESIS-64): métricas del período, * curva de despacho, nivel de servicio por operador y anomalías recientes. * - * Los agregados salen de una sola consulta por período (`useReportsOverview`). - * Hoy la resuelve un dataset de muestra porque el endpoint no existe: la - * pantalla lo avisa junto al título y `api.ts` explica por qué. + * Los agregados salen de una sola consulta por período (`useReportsOverview`) + * contra `GET /reports/overview`. Lo que el modelo no puede calcular + * —cumplimiento de plazo, anomalías— se muestra sin dato y dice por qué. */ export function ReportsPage() { const [period, setPeriod] = useState(DEFAULT_PERIOD) @@ -42,20 +41,16 @@ export function ReportsPage() { ) } - const { kpis, curve, serviceLevels, anomalies } = overview.data + const { kpis, granularity, curve, carriers, anomalies } = overview.data return ( - + - {/* La curva toma el ancho sobrante y el nivel de servicio su columna + {/* La curva toma el ancho sobrante y las entregas por operador su columna fija, como en el diseño; en pantallas angostas se apilan. */} - - + + diff --git a/src/features/reports/sampleData.ts b/src/features/reports/sampleData.ts index d906e00..cd03f96 100644 --- a/src/features/reports/sampleData.ts +++ b/src/features/reports/sampleData.ts @@ -1,9 +1,10 @@ import type { ReportsOverview } from './types' -// El dataset de muestra de S14-Reportes, tal cual lo dibuja el diseño. Es lo -// que la pantalla muestra mientras no exista el endpoint de agregados (ver -// `api.ts`), y lo que usan las pruebas de los componentes. Se borra entero -// cuando la API lo reemplace. +// El dataset de S14-Reportes tal cual lo dibuja el diseño. Ya no es lo que +// muestra la pantalla —los agregados salen de `GET /reports/overview`, ver +// `api.ts`—: queda como fixture de las pruebas de los componentes, que +// necesitan valores en todos los bloques, incluidos los que la API todavía +// devuelve vacíos. export const SAMPLE_OVERVIEW: ReportsOverview = { kpis: { @@ -23,12 +24,12 @@ export const SAMPLE_OVERVIEW: ReportsOverview = { { label: 'sáb', orders: 8_200, revenue: 690_000 }, { label: 'dom', orders: 9_400, revenue: 740_000 }, ], + granularity: 'day', // Los operadores del producto (docs/design/README.md), no los del Figma viejo. - serviceLevels: [ - { carrier: 'Andreani', onTimeRate: 99.4 }, - { carrier: 'Moova', onTimeRate: 96.8 }, - { carrier: 'Correo Argentino', onTimeRate: 94.2 }, - { carrier: 'OCASA', onTimeRate: 88.5 }, + carriers: [ + { carrier: 'Andreani', dispatched: 180, delivered: 171 }, + { carrier: 'Moova', dispatched: 95, delivered: 88 }, + { carrier: 'Correo Argentino', dispatched: 60, delivered: 41 }, ], anomalies: [ { diff --git a/src/features/reports/types.ts b/src/features/reports/types.ts index fd7a76f..9bce43b 100644 --- a/src/features/reports/types.ts +++ b/src/features/reports/types.ts @@ -1,5 +1,5 @@ -// Dominio de la pantalla de reportes (S14). Es el contrato que el endpoint de -// agregados tendrá que cumplir cuando exista: ver `api.ts`. +// Dominio de la pantalla de reportes (S14), traducido de +// `GET /api/v1/reports/overview` en `api.ts`. /** Ventana de tiempo sobre la que se calculan los agregados. */ export type ReportPeriod = '7d' | '30d' | '90d' @@ -16,17 +16,21 @@ export interface TrendedValue { } export interface ReportsKpis { - /** Unidades despachadas en el período. */ + /** Unidades de las órdenes cuyo envío se despachó en el período. */ dispatchedUnits: TrendedValue - /** Facturación del período, en pesos. */ + /** Facturación del período, en pesos, sin las órdenes canceladas. */ revenue: TrendedValue - /** Porcentaje (0-100) de entregas dentro del plazo comprometido. */ - onTimeDeliveryRate: TrendedValue + /** + * Porcentaje (0-100) de entregas dentro del plazo comprometido. `null`: el + * modelo no guarda una fecha comprometida contra la cual medirlo. + */ + onTimeDeliveryRate: TrendedValue | null + /** `null`: el sistema todavía no registra anomalías. */ activeAnomalies: { count: number /** Alguna de las anomalías abiertas es crítica: la tarjeta pasa a alerta. */ critical: boolean - } + } | null } /** Qué serie dibuja la curva de despacho. */ @@ -39,11 +43,19 @@ export interface CurvePoint { revenue: number } -export interface CarrierServiceLevel { +/** Un punto por día o por semana, según el largo del período. */ +export type CurveGranularity = 'day' | 'week' + +/** + * Envíos despachados en el período con un operador logístico, y cuántos de + * esos ya llegaron. No es el cumplimiento de plazo del diseño: sin fecha + * comprometida, es lo que el modelo puede afirmar. + */ +export interface CarrierDeliveries { /** Nombre del operador logístico, tal como lo devuelve la integración. */ carrier: string - /** Porcentaje (0-100) de entregas en plazo, con decimales. */ - onTimeRate: number + dispatched: number + delivered: number } export type AnomalyStatus = 'investigating' | 'critical' | 'resolved' @@ -60,7 +72,9 @@ export interface RegionalAnomaly { export interface ReportsOverview { kpis: ReportsKpis + granularity: CurveGranularity curve: CurvePoint[] - serviceLevels: CarrierServiceLevel[] - anomalies: RegionalAnomaly[] + carriers: CarrierDeliveries[] + /** `null`: el sistema todavía no registra anomalías (no es lo mismo que cero). */ + anomalies: RegionalAnomaly[] | null } diff --git a/src/features/reports/utils/status.test.ts b/src/features/reports/utils/status.test.ts index bb47d99..fdb4c3a 100644 --- a/src/features/reports/utils/status.test.ts +++ b/src/features/reports/utils/status.test.ts @@ -1,24 +1,6 @@ import { describe, expect, it } from 'vitest' -import { anomalyRowTone, anomalyStatusVariant, serviceLevelTone } from './status' - -describe('serviceLevelTone', () => { - // Los cuatro operadores del diseño, cada uno en su tono. - it('reproduces the four tones of the design', () => { - expect(serviceLevelTone(99.4)).toBe('primary') - expect(serviceLevelTone(96.8)).toBe('success') - expect(serviceLevelTone(94.2)).toBe('warning') - expect(serviceLevelTone(88.5)).toBe('error') - }) - - // Los umbrales son inclusivos: llegar justo al número es cumplirlo. - it('treats the threshold itself as reached', () => { - expect(serviceLevelTone(98)).toBe('primary') - expect(serviceLevelTone(95)).toBe('success') - expect(serviceLevelTone(90)).toBe('warning') - expect(serviceLevelTone(89.9)).toBe('error') - }) -}) +import { anomalyRowTone, anomalyStatusVariant } from './status' describe('anomalyStatusVariant', () => { it('maps each status to its semantic badge', () => { diff --git a/src/features/reports/utils/status.ts b/src/features/reports/utils/status.ts index 84f9972..a4a95f8 100644 --- a/src/features/reports/utils/status.ts +++ b/src/features/reports/utils/status.ts @@ -1,4 +1,4 @@ -import type { DataTableRowTone, ProgressTone, StatusVariant } from 'shared/components' +import type { DataTableRowTone, StatusVariant } from 'shared/components' import { reportsCopy } from '../content' import type { AnomalyStatus } from '../types' @@ -7,27 +7,6 @@ import type { AnomalyStatus } from '../types' // explícitos y no `switch` con default: si el backend suma un estado, el // compilador marca este archivo en vez de pintarlo en gris sin avisar. -/** - * Umbrales del nivel de servicio por operador. Son provisorios: la operación - * no tiene un acuerdo de nivel de servicio modelado todavía (comentarios de - * TESIS-64), así que reproducen los cuatro tonos del diseño hasta que el - * dominio fije los suyos. Cuando eso pase, sólo cambian estas dos constantes. - */ -const SERVICE_LEVEL_OK = 98 -const SERVICE_LEVEL_ACCEPTABLE = 95 -const SERVICE_LEVEL_AT_RISK = 90 - -/** - * Tono de la barra de un operador según su tasa de entregas en plazo: el - * acento de marca para el que cumple, y de ahí para abajo la escala semántica. - */ -export function serviceLevelTone(onTimeRate: number): ProgressTone { - if (onTimeRate >= SERVICE_LEVEL_OK) return 'primary' - if (onTimeRate >= SERVICE_LEVEL_ACCEPTABLE) return 'success' - if (onTimeRate >= SERVICE_LEVEL_AT_RISK) return 'warning' - return 'error' -} - const ANOMALY_VARIANTS: Record = { investigating: 'warning', critical: 'error',