diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index d84b845..1e009b9 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -317,7 +317,7 @@ features/[nombre]/ | `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): 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é:** @@ -350,18 +350,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',