230 lines
9.0 KiB
Markdown
230 lines
9.0 KiB
Markdown
# Etapa 5 — Columna "Eficiencia de automatización" en ComparisonTable
|
||
|
||
**Sprint:** 5
|
||
**Fecha:** 2026-06-23
|
||
**Alcance:** reporte web únicamente. PDF explícitamente fuera del scope (ver decisión al final).
|
||
|
||
---
|
||
|
||
## Contexto
|
||
|
||
`ComparisonTable` (`src/features/report/ComparisonTable.tsx`) muestra actualmente 5 columnas:
|
||
icono automatable | Actividad | Costo actual | Costo automatizado | Ahorro | % ahorro.
|
||
|
||
La tabla ordena por ahorro descendente por defecto. Falta una métrica que permita **rankear actividades por valor de automatización** — no quién ahorra más en absoluto, sino cuál tiene mejor retorno considerando su peso en el proceso.
|
||
|
||
---
|
||
|
||
## Dos métricas (A/B para focus group con clientes)
|
||
|
||
Se muestran **dos columnas** nuevas, una al lado de la otra. La hipótesis es validar con clientes reales cuál agrega más valor en una sesión de consultoría:
|
||
|
||
| Columna | Fórmula | Interpretación |
|
||
|---|---|---|
|
||
| **Ahorro esperado** | `ahorro$ × executionProbability` | Cuánto se ahorra *en promedio* por instancia del proceso, ponderando la probabilidad de que la actividad se ejecute |
|
||
| **Eficiencia** | `(ahorro$ × executionProbability) / executionTimeMinutes` | Ahorro por minuto de proceso — prioriza actividades con alto ahorro relativo a su costo de tiempo |
|
||
|
||
Ambos datos ya existen en `ActivitySimResult`:
|
||
- `ahorro$` = `actualCost - automatedCost` (ya calculado en `ComparisonRow`)
|
||
- `executionProbability` = `ActivitySimResult.executionProbability`
|
||
- `executionTimeMinutes` = `ActivitySimResult.executionTimeMinutes`
|
||
|
||
**Reglas de null (aplican a ambas columnas):**
|
||
- Si la actividad no es automatable → `null` (mostrar "—")
|
||
- Si `ahorro$ <= 0` → `null`
|
||
- Para "Eficiencia": si además `executionTimeMinutes === 0` → `null`
|
||
|
||
> **Nota de modelo:** `executionProbability` es la probabilidad de ejecución de la actividad en una instancia del proceso (calculada por el motor BPMN a partir de los gateways), no la probabilidad de éxito de la automatización. Es el dato disponible hoy. En un eventual v2 se podría agregar una probabilidad de éxito por actividad.
|
||
|
||
---
|
||
|
||
## Implementación
|
||
|
||
### 1. Agregar las dos métricas al tipo `ComparisonRow`
|
||
|
||
```ts
|
||
interface ComparisonRow {
|
||
// ... campos existentes ...
|
||
expectedSavings: number | null // ahorro$ × executionProbability
|
||
efficiency: number | null // (ahorro$ × executionProbability) / executionTimeMinutes
|
||
}
|
||
```
|
||
|
||
En el cálculo de `rows` (useMemo):
|
||
|
||
```ts
|
||
const expectedSavings =
|
||
automatable && savings > 0
|
||
? savings * act.executionProbability
|
||
: null
|
||
|
||
const efficiency =
|
||
automatable && savings > 0 && act.executionTimeMinutes > 0
|
||
? (savings * act.executionProbability) / act.executionTimeMinutes
|
||
: null
|
||
```
|
||
|
||
`act` es `ActivitySimResult` — `executionProbability` y `executionTimeMinutes` ya están disponibles en `perActivityActual`.
|
||
|
||
### 2. Cambiar el estado de sort
|
||
|
||
Reemplazar `const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')` por:
|
||
|
||
```ts
|
||
const [sortBy, setSortBy] = useState<'savings' | 'expectedSavings' | 'efficiency'>('efficiency')
|
||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||
```
|
||
|
||
Función de sort:
|
||
|
||
```ts
|
||
.sort((a, b) => {
|
||
const val = (row: ComparisonRow) => {
|
||
if (sortBy === 'efficiency') return row.efficiency ?? -Infinity
|
||
if (sortBy === 'expectedSavings') return row.expectedSavings ?? -Infinity
|
||
return row.savings
|
||
}
|
||
return (val(b) - val(a)) * (sortDir === 'desc' ? 1 : -1)
|
||
})
|
||
```
|
||
|
||
Filas con valor `null` siempre al final.
|
||
|
||
### 3. Headers de columna con sort y tooltip
|
||
|
||
Importar `Tooltip, TooltipContent, TooltipProvider, TooltipTrigger` desde `@/components/ui/tooltip`.
|
||
|
||
Patrón para cada columna nueva (repetir para "Ahorro esperado" y "Eficiencia"):
|
||
|
||
```tsx
|
||
<TableHead
|
||
className="text-xs text-right cursor-pointer text-amber-800/70 font-medium uppercase tracking-wide hover:text-amber-900"
|
||
onClick={() => {
|
||
const col = 'efficiency' // o 'expectedSavings'
|
||
if (sortBy === col) setSortDir(d => d === 'desc' ? 'asc' : 'desc')
|
||
else { setSortBy(col); setSortDir('desc') }
|
||
}}
|
||
>
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<span className="inline-flex items-center justify-end gap-1">
|
||
Eficiencia {/* o "Ahorro esperado" */}
|
||
<ArrowUpDown className={`h-3 w-3 ${sortBy === 'efficiency' ? 'opacity-80' : 'opacity-40'}`} />
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top" className="max-w-52 text-xs text-center">
|
||
{/* Para "Ahorro esperado": */}
|
||
Ahorro por instancia ponderado por probabilidad de ejecución de la actividad
|
||
{/* Para "Eficiencia": */}
|
||
Ahorro esperado dividido por tiempo de ejecución — prioriza actividades con mayor retorno por minuto de proceso
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
</TableHead>
|
||
```
|
||
|
||
La columna "Ahorro" existente también debe quedar clickeable para sort (mismo patrón, `sortBy === 'savings'`).
|
||
|
||
### 4. Celdas — versión wow: barra de progreso proporcional
|
||
|
||
Calcular máximos para cada métrica (dentro del useMemo de rows, después del sort):
|
||
|
||
```ts
|
||
const maxExpectedSavings = Math.max(
|
||
...rows.filter(r => r.expectedSavings !== null).map(r => r.expectedSavings as number), 0
|
||
)
|
||
const maxEfficiency = Math.max(
|
||
...rows.filter(r => r.efficiency !== null).map(r => r.efficiency as number), 0
|
||
)
|
||
```
|
||
|
||
Componente reutilizable para la celda con barra (puede ser una función local en el archivo):
|
||
|
||
```tsx
|
||
function MetricCell({ value, max, format }: { value: number | null; max: number; format: (v: number) => string }) {
|
||
if (value === null) return <TableCell className="text-xs text-right"><span className="text-slate-300">—</span></TableCell>
|
||
return (
|
||
<TableCell className="text-xs text-right">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<div className="w-14 h-1.5 rounded-full bg-slate-100 overflow-hidden">
|
||
<div
|
||
className="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500"
|
||
style={{ width: `${max > 0 ? (value / max) * 100 : 0}%` }}
|
||
/>
|
||
</div>
|
||
<span className="font-mono text-slate-700 text-xs w-14 text-right">
|
||
{format(value)}
|
||
</span>
|
||
</div>
|
||
</TableCell>
|
||
)
|
||
}
|
||
```
|
||
|
||
Formato de valores:
|
||
- "Ahorro esperado": `formatCurrency(value, currency)` — mismo helper que el resto de la tabla
|
||
- "Eficiencia": `value.toFixed(2)` + sufijo implícito (sin unidad visible, el tooltip explica)
|
||
|
||
### 5. Tests unitarios
|
||
|
||
Crear `tests/features/report/efficiency.test.ts`:
|
||
|
||
```ts
|
||
// Casos básicos para ambas métricas
|
||
// expectedSavings = savings × executionProbability
|
||
// efficiency = expectedSavings / executionTimeMinutes
|
||
|
||
// Edge cases:
|
||
// - savings ≤ 0 → null en ambas
|
||
// - automatable = false → null en ambas
|
||
// - executionTimeMinutes = 0 → efficiency null, expectedSavings puede ser válido
|
||
// - executionProbability = 0 → null en ambas (resultado sería 0, no rankeable)
|
||
|
||
// Ranking: filas nulas siempre al final del sort
|
||
// Sort por 'efficiency': fila nula < fila con efficiency 0.01
|
||
```
|
||
|
||
---
|
||
|
||
## Decisión de producto — focus group
|
||
|
||
Las dos columnas se implementan para **validar con clientes en talleres** cuál agrega más valor en contexto real de consultoría. No se elige una antes del focus group.
|
||
|
||
Cuando haya feedback suficiente, la siguiente decisión puede ser: mantener las dos, quedarse con una, o fusionarlas en una sola columna con toggle. Esa decisión es post Sprint 5.
|
||
|
||
---
|
||
|
||
## Scope estricto — lo que NO entra
|
||
|
||
- **PDF:** la tabla de comparación en PDF ya tiene 6 columnas en 138mm de ancho de contenido. Agregar una 7ª columna con barra de progreso no es viable en portrait sin truncar nombres de actividades. La columna de eficiencia queda fuera del PDF en esta etapa.
|
||
- Cambios en el modelo de datos (`ActivitySimResult`, `types.ts`): todos los datos necesarios ya existen.
|
||
- Cambios en el motor de simulación (`simulation.ts`): nada cambia allá.
|
||
- Cambios en `ReportPage.tsx` (salvo si hay props nuevas que pasar, que no hay).
|
||
- Cambios en `pdf-sections-roi.ts` ni en `buildComparisonTableConfig`.
|
||
|
||
---
|
||
|
||
## Criterios de validación
|
||
|
||
- [ ] Dos columnas nuevas visibles: "Ahorro esperado" y "Eficiencia", alineadas a la derecha
|
||
- [ ] Actividades no automatizables muestran "—" en ambas columnas
|
||
- [ ] Sort por defecto: mayor eficiencia primero (desc)
|
||
- [ ] Click en cada columna activa el sort por esa métrica; click nuevamente alterna asc/desc
|
||
- [ ] El ícono ArrowUpDown es más opaco en la columna de sort activa
|
||
- [ ] Barra de progreso proporcional en ambas columnas: el máximo = 100% del ancho
|
||
- [ ] Tooltip aparece al hacer hover en el header de cada columna nueva
|
||
- [ ] Tests unitarios de ambas fórmulas en Vitest: verdes, incluyendo edge cases
|
||
- [ ] `npm run lint` sin errores
|
||
- [ ] `npm run build` limpio
|
||
- [ ] Validación visual del director antes del OK formal
|
||
|
||
---
|
||
|
||
## Archivos a modificar
|
||
|
||
1. `src/features/report/ComparisonTable.tsx` — todo el trabajo de la feature
|
||
2. `tests/features/report/efficiency.test.ts` (nuevo) — tests unitarios de la fórmula
|
||
|
||
No modificar ningún otro archivo.
|