Los cambios en reporte de eficiencia
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-06-23 17:09:54 -03:00
parent ecfb72c3f1
commit bf200b72f9
5 changed files with 813 additions and 25 deletions

View File

@@ -0,0 +1,229 @@
# 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.

View File

@@ -0,0 +1,306 @@
# Etapa 6 — Limpieza técnica: E2E Supabase + flakiness + documentación
**Sprint:** 5
**Fecha:** 2026-06-23
**Alcance:** tests E2E, botón ActivityPanel, documentación de cierre de sprint.
---
## Contexto
Sprint 4 migró la persistencia de IndexedDB (Dexie) a Supabase. Dos specs E2E quedaron permanentemente rotos porque inyectan datos vía IndexedDB directamente:
- `tests/e2e/export-pdf.spec.ts`
- `tests/e2e/validate-dod-etapa4.spec.ts`
Adicionalmente, 3 specs fallan intermitentemente por timing al buscar el botón "Guardar cambios" antes de que esté habilitado.
Esta etapa no toca código de producto. Solo tests, un `data-testid` y documentación.
---
## 6a — Reescribir specs Dexie → Supabase
### El problema
Ambos specs usan `page.evaluate()` para abrir IndexedDB directamente y escribir datos:
```ts
// patrón antiguo — NO funciona con Supabase
const db = await new Promise<IDBDatabase>((res, rej) => {
const req = indexedDB.open('ProcessCostPlatform')
req.onsuccess = () => res(req.result)
// ...
})
```
Dexie/IndexedDB fue eliminado en Sprint 4. Las tablas `ProcessCostPlatform` no existen.
### La solución
Reemplazar la inyección de datos por llamadas a la API REST de Supabase usando el `service_role_key` (bypasa RLS). El patrón es:
```ts
// en beforeAll / dentro del test
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY)
const { data: activities } = await supabase
.from('activities')
.select('id, bpmn_element_id')
.eq('process_id', processId)
await supabase.from('activities').upsert(
activities.map(act => ({
id: act.id,
direct_cost_fixed: ACTUAL_COST[act.bpmn_element_id] ?? 200,
execution_time_minutes: 60,
automatable: AUTOMATABLE.has(act.bpmn_element_id),
automated_cost_fixed: AUTOMATABLE.has(act.bpmn_element_id) ? AUTO_COST : 0,
automated_time_minutes: AUTOMATABLE.has(act.bpmn_element_id) ? AUTO_TIME : 0,
}))
)
```
**Credenciales:** leer de `.env.test` (ya existe, gitignored). Agregar si no existe:
```
SUPABASE_SERVICE_ROLE_KEY=<service_role_key>
```
Ver `.env.example` para el formato esperado.
**Cleanup post-test:** en `afterAll`, borrar el proceso creado por el test (elimina en cascada actividades y simulaciones):
```ts
afterAll(async () => {
if (createdProcessId) {
await supabase.from('processes').delete().eq('id', createdProcessId)
}
})
```
### Estrategia de implementación
1. El flujo UI se mantiene igual: import BPMN → esperar workspace → capturar `processId` de la URL
2. **Reemplazar** el bloque de `page.evaluate(... indexedDB ...)` por llamadas Supabase directas (no desde el browser, sino desde el proceso de Node.js del test)
3. Después de inyectar los datos, hacer `await page.reload()` para que el store de Zustand recargue desde Supabase (mismo patrón que tenía antes)
4. Continuar con el flujo de simulación y export igual que antes
### Notas de esquema
Las columnas de Supabase usan snake_case. Los campos relevantes en `activities`:
- `direct_cost_fixed` (número, costo fijo por ejecución)
- `execution_time_minutes`
- `automatable` (boolean)
- `automated_cost_fixed`
- `automated_time_minutes`
- `process_id`
- `bpmn_element_id`
Verificar el esquema real ejecutando:
```ts
const { data } = await supabase.from('activities').select('*').limit(1)
console.log(Object.keys(data[0]))
```
antes de escribir el upsert definitivo.
Para el proceso, las columnas relevantes:
- `annual_frequency`
- `analysis_horizon_years`
- `automation_investment`
### 6a-extra — Diagnóstico y fix de `auth-setup.ts` (probable bug)
**Diagnóstico a realizar antes de continuar con 6a:**
`tests/e2e/setup/auth-setup.ts` guarda la sesión en localStorage bajo la clave:
```ts
const storageKey = `sb-${projectRef}-auth-token`
```
Sin embargo, `@supabase/auth-js` v2 usa la clave **`supabase.auth.token`** (constante `STORAGE_KEY` en `node_modules/@supabase/auth-js/dist/main/lib/constants.js`). Esta discrepancia existe desde Sprint 4 cuando se configuró el setup.
**Verificar:**
1. Correr `npx playwright test tests/e2e/sprint-4-smoke.spec.ts` y observar si los tests "Con sesión" realmente obtienen datos de Supabase o si pasan vacíos (sin auth real)
2. Ejecutar el test `app header shows user avatar` — si pasa sin auth real, hay algo raro
3. Leer la constante en `node_modules/@supabase/auth-js/dist/main/lib/constants.js` para confirmar la clave correcta
**Fix si se confirma el bug:**
En `auth-setup.ts`, cambiar:
```ts
const projectRef = new URL(supabaseUrl).hostname.split('.')[0]
const storageKey = `sb-${projectRef}-auth-token`
```
por:
```ts
const storageKey = 'supabase.auth.token'
```
**Importante:** si `auth-state.json` ya existe con la clave incorrecta, regenerarlo corriendo el setup tras el fix. El archivo es gitignored — puede regenerarse sin afectar a otros.
**Impacto:** si el bug existe, todos los specs con auth que leen datos de Supabase son inválidos (pasan porque no hay assertions que fallen con datos vacíos, no porque el auth funcione). Esto sería deuda crítica que afecta la confiabilidad de toda la suite E2E.
Si el bug **no** se confirma (la clave ya es correcta por alguna razón), documentarlo en un comentario en `auth-setup.ts` explicando por qué difiere de la constante de la librería.
---
## 6b — Fix flakiness "Guardar cambios"
### El problema
El botón en `ActivityPanel.tsx` (línea ~592) es:
```tsx
<Button
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar cambios' : 'Sin cambios'}
</Button>
```
No tiene `data-testid`. Los specs lo buscan con `page.getByRole('button', { name: /Guardar/i })` sin esperar a que esté habilitado. Cuando `isDirty = false` (estado inicial), el botón dice "Sin cambios" y está deshabilitado — Playwright lo encuentra pero falla al hacer click.
### Fix en `ActivityPanel.tsx`
Agregar `data-testid="save-activity-btn"` al botón:
```tsx
<Button
data-testid="save-activity-btn"
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar cambios' : 'Sin cambios'}
</Button>
```
### Fix en los specs afectados
Identificar los 3 specs que tienen el patrón flaky (buscar `getByRole('button', { name: /Guardar/i })`):
- `tests/e2e/payback-etapa-2.spec.ts` — función `configureActivity`
- `tests/e2e/export-roi.spec.ts` — función `configureActivity`
- Cualquier otro spec que use el mismo patrón
Reemplazar el patrón de click sin wait:
```ts
// ANTES (flaky)
const guardar = page.getByRole('button', { name: /Guardar/i })
await guardar.click()
await page.waitForTimeout(500)
// DESPUÉS (determinístico)
await page.waitForSelector('[data-testid="save-activity-btn"]:not([disabled])', { timeout: 10_000 })
await page.click('[data-testid="save-activity-btn"]')
// Esperar confirmación de guardado (desaparece el isDirty):
await page.waitForSelector('[data-testid="save-activity-btn"][disabled]', { timeout: 10_000 })
```
---
## 6c — Documentación de cierre de sprint
### BACKLOG.md — marcar como RESOLVED
Verificar si estos ítems siguen en estado pendiente y actualizarlos:
1. `[SPRINT-5] Indicador de simulación desactualizada` — implementado en Sprint 3
2. `[SPRINT-5] Edición inline de proceso/cliente` — implementado en Sprint 2
3. `[SPRINT-5] Controles de zoom BPMN` — implementado en Sprint 5 Etapa 2
4. `[SPRINT-5] Columna "Eficiencia de automatización"` — implementado en Sprint 5 Etapa 5
Para cada uno: cambiar el tag a `[RESOLVED]` y agregar la línea `**Sprint cerrado en:** Sprint N Etapa X`.
### STRATEGIC_DIRECTION.md — agregar cierre Sprint 4 y Sprint 5
Si el archivo existe, agregar una sección de cierre para ambos sprints:
**Sprint 4 (cerrado 2026-06-18):**
- Decisión estratégica: refactor arquitectural completo (Supabase + auth). Sin features de producto nuevas. Justificación: la deuda técnica de IndexedDB bloqueaba features colaborativas.
- Resultado: auth Google OAuth + Supabase, migración IndexedDB→PostgreSQL, RLS, catálogo global de recursos. ~550+ tests.
**Sprint 5 (cerrado 2026-06-23):**
- Decisión estratégica: retorno al rebalanceo normal (60-70% features web). UX polish + quick wins.
- Resultado: fix workspace loading, zoom BPMN, heatmap gradiente 4 stops + toggle, recursos por proceso, campo empresa, columna eficiencia, reescritura E2E Supabase.
### OBSERVATIONS.md — preparar para Sprint 6
Una vez validadas todas las etapas del Sprint 5:
1. Mover hallazgos pendientes a TECH_DEBT.md o BACKLOG.md según corresponda
2. Reemplazar el contenido con el encabezado limpio para Sprint 6:
```markdown
# OBSERVATIONS — Sprint 6
> Hallazgos descubiertos durante el sprint actual.
> ...
## Hallazgos activos
*(vacío)*
```
### TECH_DEBT.md — marcar RESOLVED los ítems resueltos en Etapa 6
- `[BACKLOG] E2E specs con Dexie directo``[RESOLVED]` con nota de la solución
- `[BACKLOG] E2E flakiness — "Guardar cambios" button timing``[RESOLVED]` con nota
---
## Criterios de validación
### Tests
- [ ] `npx playwright test tests/e2e/export-pdf.spec.ts` — verde (o skip explícito si el test user no está disponible en el entorno local, con comentario en el spec)
- [ ] `npx playwright test tests/e2e/validate-dod-etapa4.spec.ts` — verde
- [ ] Los 3 specs flaky de "Guardar cambios" corren 3 veces seguidas sin fallar
- [ ] `npx playwright test tests/e2e/sprint-4-smoke.spec.ts` — sin regresiones
- [ ] `npm run test` — mantener o superar conteo anterior de Vitest
### Código
- [ ] `data-testid="save-activity-btn"` presente en `ActivityPanel.tsx`
- [ ] `npm run lint` sin errores
- [ ] `npm run build` limpio
### Documentación
- [ ] BACKLOG.md actualizado
- [ ] STRATEGIC_DIRECTION.md con cierres Sprint 4 y 5
- [ ] OBSERVATIONS.md preparado para Sprint 6
### Validación del director
- [ ] Revisar los 3 archivos de documentación antes del OK formal
---
## Orden de ejecución recomendado
1. **Primero:** diagnóstico de `auth-setup.ts` (6a-extra) — determina si la suite E2E con auth funciona realmente
2. **Segundo:** fix de `auth-setup.ts` si aplica + regeneración de `auth-state.json`
3. **Tercero:** `data-testid` en ActivityPanel (6b) — bajo esfuerzo, sin riesgo
4. **Cuarto:** fix de timing en specs (6b) — depende del `data-testid`
5. **Quinto:** reescritura E2E Dexie→Supabase (6a) — depende de que auth funcione
6. **Último:** documentación (6c) — solo cuando todo lo técnico esté verde
---
## Archivos a modificar
1. `tests/e2e/setup/auth-setup.ts` — fix localStorage key (si se confirma el bug)
2. `tests/e2e/export-pdf.spec.ts` — reescribir inyección de datos (Dexie → Supabase)
3. `tests/e2e/validate-dod-etapa4.spec.ts` — reescribir inyección de datos (Dexie → Supabase)
4. `tests/e2e/payback-etapa-2.spec.ts` — fix timing guardar
5. `tests/e2e/export-roi.spec.ts` — fix timing guardar
6. `src/features/workspace/ActivityPanel.tsx` — agregar `data-testid`
7. `BACKLOG.md` — actualizar estados
8. `STRATEGIC_DIRECTION.md` — agregar cierres Sprint 4 y 5
9. `OBSERVATIONS.md` — preparar para Sprint 6
10. `TECH_DEBT.md` — marcar resueltos
No modificar archivos de código de producto (src/) salvo el `data-testid` en ActivityPanel.
---
## Nota operativa
Si los specs E2E de Supabase no corren en el entorno local (falta `SUPABASE_SERVICE_ROLE_KEY` en `.env.test`), documentar el procedimiento en `.env.example` y agregar un `test.skip` condicional con mensaje descriptivo en lugar de dejar los tests rojos. Un skip explícito es mejor que un test permanentemente rojo.

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'
import { Bot, ArrowUpDown } from 'lucide-react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { formatCurrency, formatPercent } from '@/lib/format'
import type { ActivitySimResult } from '@/domain/types'
@@ -12,8 +13,12 @@ interface ComparisonRow {
savings: number
savingsPercent: number
automatable: boolean
expectedSavings: number | null
efficiency: number | null
}
type SortBy = 'savings' | 'expectedSavings' | 'efficiency'
interface ComparisonTableProps {
perActivityActual: ActivitySimResult[]
perActivityAutomated: ActivitySimResult[]
@@ -21,12 +26,46 @@ interface ComparisonTableProps {
currency: string
}
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>
)
}
export function ComparisonTable({
perActivityActual,
perActivityAutomated,
automatableIds,
currency,
}: ComparisonTableProps) {
const [sortBy, setSortBy] = useState<SortBy>('efficiency')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const automatedByActivityId = useMemo(() => {
@@ -36,25 +75,53 @@ export function ComparisonTable({
}, [perActivityAutomated])
const rows: ComparisonRow[] = useMemo(() => {
return perActivityActual
.map((act) => {
const automated = automatedByActivityId.get(act.activityId)
const automatedCost = automated?.expectedTotalCost ?? act.expectedTotalCost
const savings = act.expectedTotalCost - automatedCost
const savingsPercent =
act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
return {
activityId: act.activityId,
activityName: act.activityName,
actualCost: act.expectedTotalCost,
automatedCost,
savings,
savingsPercent,
automatable: automatableIds.has(act.bpmnElementId),
}
})
.sort((a, b) => (b.savings - a.savings) * (sortDir === 'desc' ? 1 : -1))
}, [perActivityActual, automatedByActivityId, automatableIds, sortDir])
const built = perActivityActual.map((act) => {
const automated = automatedByActivityId.get(act.activityId)
const automatedCost = automated?.expectedTotalCost ?? act.expectedTotalCost
const savings = act.expectedTotalCost - automatedCost
const savingsPercent =
act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
const automatable = automatableIds.has(act.bpmnElementId)
const expectedSavings =
automatable && savings > 0 ? savings * act.executionProbability : null
const efficiency =
automatable && savings > 0 && act.executionTimeMinutes > 0
? (savings * act.executionProbability) / act.executionTimeMinutes
: null
return {
activityId: act.activityId,
activityName: act.activityName,
actualCost: act.expectedTotalCost,
automatedCost,
savings,
savingsPercent,
automatable,
expectedSavings,
efficiency,
}
})
return built.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)
})
}, [perActivityActual, automatedByActivityId, automatableIds, sortBy, sortDir])
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
)
if (rows.length === 0) {
return (
@@ -64,6 +131,14 @@ export function ComparisonTable({
)
}
function toggleSort(col: SortBy) {
if (sortBy === col) setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'))
else {
setSortBy(col)
setSortDir('desc')
}
}
return (
<div className="rounded-xl border border-inq-orange/20 overflow-hidden">
<Table>
@@ -75,14 +150,50 @@ export function ComparisonTable({
<TableHead className="text-xs text-right text-amber-800/70 font-medium uppercase tracking-wide">Costo automatizado</TableHead>
<TableHead
className="text-xs text-right cursor-pointer select-none text-amber-800/70 font-medium uppercase tracking-wide hover:text-amber-900"
onClick={() => setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'))}
onClick={() => toggleSort('savings')}
>
<span className="inline-flex items-center justify-end gap-1">
Ahorro
<ArrowUpDown className="h-3 w-3 opacity-40" />
<ArrowUpDown className={`h-3 w-3 ${sortBy === 'savings' ? 'opacity-80' : 'opacity-40'}`} />
</span>
</TableHead>
<TableHead className="text-xs text-right text-amber-800/70 font-medium uppercase tracking-wide">% ahorro</TableHead>
<TableHead
className="text-xs text-right cursor-pointer select-none text-amber-800/70 font-medium uppercase tracking-wide hover:text-amber-900"
onClick={() => toggleSort('expectedSavings')}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center justify-end gap-1">
Ahorro esperado
<ArrowUpDown className={`h-3 w-3 ${sortBy === 'expectedSavings' ? 'opacity-80' : 'opacity-40'}`} />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-52 text-xs text-center">
Ahorro por instancia ponderado por probabilidad de ejecución de la actividad
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableHead>
<TableHead
className="text-xs text-right cursor-pointer select-none text-amber-800/70 font-medium uppercase tracking-wide hover:text-amber-900"
onClick={() => toggleSort('efficiency')}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center justify-end gap-1">
Eficiencia
<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">
Ahorro esperado dividido por tiempo de ejecución prioriza actividades con mayor retorno por minuto de proceso
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableHead>
</tr>
</TableHeader>
<TableBody>
@@ -124,6 +235,16 @@ export function ComparisonTable({
? (row.savings > 0 ? '+' : '') + formatPercent(row.savingsPercent)
: '—'}
</TableCell>
<MetricCell
value={row.expectedSavings}
max={maxExpectedSavings}
format={(v) => formatCurrency(v, currency)}
/>
<MetricCell
value={row.efficiency}
max={maxEfficiency}
format={(v) => v.toFixed(2)}
/>
</TableRow>
)
})}

View File

@@ -0,0 +1,126 @@
/**
* Tests de las fórmulas "Ahorro esperado" y "Eficiencia" de ComparisonTable (Sprint 5, Etapa 5).
* expectedSavings = savings × executionProbability
* efficiency = expectedSavings / executionTimeMinutes
* Reglas de null: no automatable → null; savings <= 0 → null; efficiency además requiere
* executionTimeMinutes > 0.
*
* Las fórmulas viven inline en el useMemo de ComparisonTable.tsx (no hay función pura
* exportada — por scope de la etapa no se modifica el dominio). Estos tests replican
* exactamente esa lógica para verificar las fórmulas y las reglas de null/ranking.
*/
import { describe, it, expect } from 'vitest'
interface Activity {
savings: number
automatable: boolean
executionProbability: number
executionTimeMinutes: number
}
function computeExpectedSavings(act: Activity): number | null {
return act.automatable && act.savings > 0 ? act.savings * act.executionProbability : null
}
function computeEfficiency(act: Activity): number | null {
return act.automatable && act.savings > 0 && act.executionTimeMinutes > 0
? (act.savings * act.executionProbability) / act.executionTimeMinutes
: null
}
describe('ComparisonTable — fórmula Ahorro esperado', () => {
it('calcula savings × executionProbability', () => {
const act: Activity = { savings: 540, automatable: true, executionProbability: 0.8, executionTimeMinutes: 60 }
expect(computeExpectedSavings(act)).toBeCloseTo(432)
})
it('probability 1.0 → expectedSavings === savings', () => {
const act: Activity = { savings: 270, automatable: true, executionProbability: 1.0, executionTimeMinutes: 30 }
expect(computeExpectedSavings(act)).toBe(270)
})
it('no automatable → null', () => {
const act: Activity = { savings: 540, automatable: false, executionProbability: 1.0, executionTimeMinutes: 60 }
expect(computeExpectedSavings(act)).toBeNull()
})
it('savings <= 0 → null', () => {
const act: Activity = { savings: 0, automatable: true, executionProbability: 1.0, executionTimeMinutes: 60 }
expect(computeExpectedSavings(act)).toBeNull()
const negative: Activity = { savings: -100, automatable: true, executionProbability: 1.0, executionTimeMinutes: 60 }
expect(computeExpectedSavings(negative)).toBeNull()
})
it('executionProbability 0 → resultado 0, no null (0 sigue siendo un valor válido salvo que savings <= 0)', () => {
const act: Activity = { savings: 540, automatable: true, executionProbability: 0, executionTimeMinutes: 60 }
expect(computeExpectedSavings(act)).toBe(0)
})
})
describe('ComparisonTable — fórmula Eficiencia', () => {
it('calcula (savings × executionProbability) / executionTimeMinutes', () => {
const act: Activity = { savings: 540, automatable: true, executionProbability: 1.0, executionTimeMinutes: 60 }
expect(computeEfficiency(act)).toBeCloseTo(9.0)
})
it('considera executionProbability fraccionaria', () => {
const act: Activity = { savings: 300, automatable: true, executionProbability: 0.5, executionTimeMinutes: 30 }
expect(computeEfficiency(act)).toBeCloseTo(5.0)
})
it('no automatable → null', () => {
const act: Activity = { savings: 540, automatable: false, executionProbability: 1.0, executionTimeMinutes: 60 }
expect(computeEfficiency(act)).toBeNull()
})
it('savings <= 0 → null', () => {
const act: Activity = { savings: -50, automatable: true, executionProbability: 1.0, executionTimeMinutes: 60 }
expect(computeEfficiency(act)).toBeNull()
})
it('executionTimeMinutes === 0 → null aunque savings sea válido', () => {
const act: Activity = { savings: 540, automatable: true, executionProbability: 1.0, executionTimeMinutes: 0 }
expect(computeEfficiency(act)).toBeNull()
})
it('executionTimeMinutes === 0 pero expectedSavings sigue siendo válido (no null)', () => {
const act: Activity = { savings: 540, automatable: true, executionProbability: 1.0, executionTimeMinutes: 0 }
expect(computeExpectedSavings(act)).toBe(540)
expect(computeEfficiency(act)).toBeNull()
})
it('executionProbability 0 → resultado 0, no null', () => {
const act: Activity = { savings: 540, automatable: true, executionProbability: 0, executionTimeMinutes: 60 }
expect(computeEfficiency(act)).toBe(0)
})
})
describe('ComparisonTable — ranking con valores null', () => {
function rankByEfficiency(acts: Activity[]): (number | null)[] {
return acts
.map(computeEfficiency)
.sort((a, b) => (b ?? -Infinity) - (a ?? -Infinity))
}
it('filas con efficiency null quedan siempre al final del sort descendente', () => {
const acts: Activity[] = [
{ savings: -10, automatable: true, executionProbability: 1.0, executionTimeMinutes: 60 }, // null
{ savings: 600, automatable: true, executionProbability: 1.0, executionTimeMinutes: 60 }, // 10
{ savings: 60, automatable: true, executionProbability: 1.0, executionTimeMinutes: 60 }, // 1
]
const ranked = rankByEfficiency(acts)
expect(ranked[0]).toBeCloseTo(10)
expect(ranked[1]).toBeCloseTo(1)
expect(ranked[ranked.length - 1]).toBeNull()
})
it('fila nula siempre por debajo de una fila con efficiency 0.01', () => {
const acts: Activity[] = [
{ savings: -5, automatable: true, executionProbability: 1.0, executionTimeMinutes: 60 }, // null
{ savings: 0.6, automatable: true, executionProbability: 1.0, executionTimeMinutes: 60 }, // 0.01
]
const ranked = rankByEfficiency(acts)
expect(ranked[0]).toBeCloseTo(0.01)
expect(ranked[1]).toBeNull()
})
})

View File

@@ -286,17 +286,23 @@ describe('ComparisonTable', () => {
currency="USD"
/>
)
// Buscar el th que contiene "Ahorro" (hay múltiples elementos con ese texto)
// Buscar el th que contiene "Ahorro" exacto (no "Ahorro esperado" — hay múltiples
// columnas cuyo texto incluye "Ahorro" desde Etapa 5)
const headers = screen.getAllByRole('columnheader')
const ahorroHeader = headers.find((h) => /Ahorro/i.test(h.textContent ?? ''))
const ahorroHeader = headers.find((h) => /^Ahorro$/i.test((h.textContent ?? '').trim()))
expect(ahorroHeader).toBeDefined()
// Antes del click: orden desc (a1=540 primero)
// Sort por defecto es 'efficiency' (Etapa 5). Con esta fixture, executionProbability
// y executionTimeMinutes son iguales en todas las filas, así que ordenar por
// efficiency-desc y por savings-desc da el mismo orden visual (a1 primero).
const rowsBefore = screen.getAllByRole('row').slice(1)
const firstNameBefore = rowsBefore[0].textContent
// Primer click: cambia la columna activa a 'savings' (desc) — el orden no cambia
// para esta fixture porque coincide con el de 'efficiency'.
fireEvent.click(ahorroHeader!)
// Segundo click: misma columna ya activa → invierte a asc (a3/null al final, a2 primero)
fireEvent.click(ahorroHeader!)
// Después del click: orden asc (a3=0 primero)
const rowsAfter = screen.getAllByRole('row').slice(1)
const firstNameAfter = rowsAfter[0].textContent