Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions docs/guidelines/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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é:**

Expand Down Expand Up @@ -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 `<ol>` 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 `<ol>` 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é:**

Expand Down
68 changes: 68 additions & 0 deletions src/features/reports/api.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
])
})
})
117 changes: 97 additions & 20 deletions src/features/reports/api.ts
Original file line number Diff line number Diff line change
@@ -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<ReportsOverview> {
const { data } = await client.get<ApiOverview>('/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<ReportsOverview> {
return Promise.resolve(SAMPLE_OVERVIEW)
return toOverview(data)
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function AnomaliesTable({ anomalies }: AnomaliesTableProps) {
return (
<DataTable
columns={COLUMNS}
rows={anomalies}
rows={anomalies ?? []}
getRowId={(anomaly) => anomaly.id}
label={copy.tableLabel}
title={copy.title}
Expand All @@ -90,7 +90,7 @@ export function AnomaliesTable({ anomalies }: AnomaliesTableProps) {
</Box>
</Tooltip>
}
emptyMessage={copy.empty}
emptyMessage={anomalies === null ? copy.unavailable : copy.empty}
/>
)
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const AXIS_FORMAT: Record<CurveMetric, (value: number) => 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<CurveMetric>('orders')

// El segmentado es exclusivo: soltar la opción activa devuelve `null`, y una
Expand Down Expand Up @@ -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)}
/>
</CurveCard>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Loading
Loading