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',