feat(sprint-1-5): cierre Etapa 2 - formatPayback + fixes de marca

[
This commit is contained in:
2026-05-16 19:26:19 -03:00
parent f9a008bce5
commit eaaac88fc3
16 changed files with 1626 additions and 55 deletions

View File

@@ -285,4 +285,12 @@ npx playwright test # tests E2E (Playwright headless)
- Gradiente identidad: #F59845#ED3E8F
- NO usar morado CONCILIA (#572FA2, #CB1889)
- Footer obligatorio: "Powered by InQuality"
- No promover a Marcos como creador
- No promover a Marcos como creador
## Verificación final antes de present_files
Antes de presentar cualquier PDF al usuario, verificá que:
1. El header/footer dice "InQ ROI" (no "Process Cost Platform" ni otro)
2. El footer incluye "Powered by InQuality"
3. No hay caracteres rotos (símbolos sin renderizar)
4. La marca "InQ" se escribe siempre I-n-Q

View File

@@ -35,6 +35,8 @@
- [ ] **Columna "Eficiencia de automatización" en ComparisonTable**: métrica = `(ahorro$ × probabilidad) / inversión-prorrateada`. Permite rankear actividades por "qué tan valiosa es automatizar cada una" en lugar de solo "cuánto cuestan". Útil en conversaciones de pre-venta donde el cliente quiere priorizar qué automatizar primero.
- [ ] **ComparisonTable — actividades no automatizables muestran "USD 0,00" y "0,0%"**: semánticamente incorrecto. El ahorro de una actividad no automatizable no es "cero", es "no aplica". Cambiar por "—" (em dash) en columnas Ahorro y % Ahorro para filas donde `isAutomatable === false`. Implementar en Etapa 3 al rediseñar ComparisonTable con paleta InQ. (detectado en Sprint 1.5 Etapa 2)
## Baja prioridad / Fase 2
- [ ] **Login y multi-tenant**: persistencia en la nube, colaboración

871
sprints/sprint-1-5/BRIEF.md Normal file
View File

@@ -0,0 +1,871 @@
# Sprint 1.5 — Brief de Rediseño Visual
> **Propósito:** rediseñar la identidad visual del producto (PDFs + Web App) con manual de marca InQ ROI antes del cierre de Sprint 1. No agrega nuevas features funcionales; refina presentación, identidad y polish.
> **Justificación:** los PDFs y la web app actuales son funcionales pero genéricos. El producto va a ser usado en pre-venta, talleres y charlas de InQuality, requiere identidad sólida desde el inicio. La iteración visual es 510× más barata ahora que después.
---
## 0. Contexto crítico
Este producto es **una herramienta de InQuality**, no un producto independiente. Se construye con identidad propia (color naranja InQ) pero parte de la familia visual de productos InQuality (junto con CONCILIA, que usa morado). Lleva la marca "Powered by InQuality" en footers.
**Regla NO NEGOCIABLE de marca:** la versión corta de InQuality SIEMPRE se escribe **InQ** — I mayúscula, n minúscula, Q mayúscula. Cualquier desviación (InQ, INQ, Inq, inQ) es bug.
---
## 1. Manual de marca InQ ROI
### 1.1 Paleta cromática
#### Color protagonista — Naranja InQ
| Token CSS | Hex | Uso |
|---|---|---|
| `--inq-orange` | `#F59845` | Principal. Logo, KPIs hero, headers, CTAs primarios |
| `--inq-orange-dark` | `#B45309` | Texto destacado sobre fondos claros, hover de CTAs |
| `--inq-orange-light` | `#FEF3E2` | Fondos de KPI cards, fondos sutiles |
| `--inq-orange-lighter` | `#FFF8ED` | Fondos de banner narrativo, hover states muy sutiles |
#### Gradiente hero (sólo para KPIs hero y banners principales)
```css
background: linear-gradient(135deg, #F59845 0%, #ED3E8F 100%);
```
Donde `#ED3E8F` es el magenta InQ. Este gradiente representa la familia visual InQuality y se usa SOLO en:
- KPI hero "Ahorro proyectado a 3 años" (PDF y web)
- Carátulas o banners principales (si aparecen)
- NUNCA en bordes, separadores, fondos secundarios
#### Colores semánticos
| Token CSS | Hex | Uso |
|---|---|---|
| `--color-success` | `#16A34A` | Ahorros positivos, "óptimo" |
| `--color-success-light` | `#F0FDF4` | Fondos de mensajes positivos |
| `--color-warning` | `#F59E0B` | Advertencias, sección transparencia |
| `--color-warning-bg` | `#FFFBEB` | Fondos de advertencia |
| `--color-danger` | `#EF4444` | Costos altos, errores |
| `--color-danger-light` | `#FEE2E2` | Fondos de heatmap (alto costo) |
#### Heatmap (gradient de costo) — DECISIÓN: mantener colores saturados
```css
/* Bajo → Medio → Alto */
heatmap-low: #10B981; /* verde saturado */
heatmap-mid: #F59E0B; /* ámbar saturado */
heatmap-high: #EF4444; /* rojo saturado */
```
Razón: discriminan mejor visualmente en pantallas grandes (talleres, charlas) y mantienen calibración de los E2E de análisis de píxeles. Revisar en Sprint 3-4 con feedback de uso real.
#### Neutrales (tipografía y bordes)
| Token CSS | Hex | Uso |
|---|---|---|
| `--text-primary` | `#0F172A` | Títulos, números importantes |
| `--text-secondary` | `#475569` | Texto del cuerpo |
| `--text-tertiary` | `#64748B` | Labels, metadatos |
| `--text-disabled` | `#94A3B8` | Placeholders, texto muy secundario |
| `--border-primary` | `#E2E8F0` | Bordes generales |
| `--border-light` | `#F1F5F9` | Separadores sutiles |
| `--bg-app` | `#F8FAFC` | Fondo de la app |
| `--bg-card` | `#FAFBFC` | Fondo de tarjetas secundarias |
### 1.2 Tipografía
#### Familia
- **Primaria** (web app y PDFs): system-ui stack, con fallback a Roboto embebido en PDFs.
```css
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
```
- **Mono** (números en tablas, datos financieros): `'JetBrains Mono', 'SF Mono', Consolas, monospace`.
#### Jerarquía de tamaños (PDF)
| Elemento | Tamaño | Peso | Tracking |
|---|---|---|---|
| Logo "InQ ROI" en header | 22px | 700 | -0.8px |
| Título sección (page title) | 16px | 500 | normal |
| KPI Hero (cifra grande) | 30px | 700 | -1px |
| KPI secundario (cifra) | 16px | 700 | normal |
| Body text | 11px | 400 | normal |
| Labels en cards | 8px | 500 | 1px (uppercase) |
| Tabla — header | 10px | 500 | normal |
| Tabla — body | 10px | 400 | normal |
| Footer | 9px | 400 | normal |
#### Jerarquía de tamaños (Web App)
| Elemento | Tamaño |
|---|---|
| Logo header | 18px / 700 |
| Page title | 16px / 500 |
| KPI Hero | 28px / 700 |
| KPI secundario | 16px / 700 |
| Nav / Tabs | 12px / 400-500 |
| Body | 12-13px / 400 |
| Labels en panels | 10-11px / 400-500 |
### 1.3 Reglas de uso del logo "InQ ROI"
- **Tipografía:** sans-serif bold con tracking apretado (-0.5px a -0.8px)
- **Color por defecto:** `#F59845`
- **Color sobre gradiente o fondos oscuros:** blanco
- **Posición en PDFs:** esquina superior derecha del header de cada página
- **Posición en Web App:** esquina superior izquierda del topbar
- **Mínimo aire alrededor:** 16px en todas direcciones
- **Aclaración "Powered by InQuality":** SIEMPRE en footer, no en header
### 1.4 Iconografía
- **Sistema:** Lucide React (ya instalado)
- **Tamaños estándar:** 12px, 14px, 16px, 20px, 24px
- **Color por defecto:** hereda del contexto (`currentColor`)
- **Ícono "Automatizable" (⚡):** símbolo emoji ⚡ por ahora. Cambiar a `<Bot />` de Lucide en Sprint 2-3 (anotar en TODO.md)
- **Badge automatizable:** círculo `#F59845` con ⚡ blanco centrado, tamaño 14-22px según contexto
### 1.5 Accesibilidad — Contraste
El naranja InQ `#F59845` sobre blanco tiene contraste WCAG ~2.42:1.
- **AA normal text (4.5:1):** FAIL
- **AA large text (3.0:1):** FAIL (apenas por debajo)
Decisión: aplicar `font-weight: 700` en CTAs primarios para acercarse al cumplimiento AA large, asumiendo el costo en accesibilidad estricta como decisión consciente. Documentado en audit.
---
## 2. Sistema de diseño — Componentes clave
### 2.1 KPI Card (cifra grande)
```
Fondo: #FEF3E2 (inq-orange-light)
Padding: 10px 12px
Border-radius: 6px
Label: 8px / 500 / letter-spacing 1px / color #92400E / uppercase
Cifra: 16px / 700 / color #0F172A
Delta: 10px / 500 / color #B45309 (verde-orange-dark)
```
### 2.2 KPI Hero (gradiente)
```
Fondo: linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)
Padding: 16-18px
Border-radius: 6-8px
Label: 9-10px / opacity 0.9 / letter-spacing 1.2px / white
Cifra: 28-30px / 700 / letter-spacing -1px / white
Subtítulo: 10-11px / opacity 0.85 / white
```
### 2.3 Banner narrativo (resumen ejecutivo)
```
Fondo: #FFF8ED (inq-orange-lighter)
Border-left: 3px solid #F59845
Padding: 10-12px 14px
Border-radius: 0 4px 4px 0
Texto: 11px / 400 / line-height 1.55 / color #475569
```
### 2.4 Sección Transparencia Metodológica
```
Fondo: #FFFBEB (warning-bg)
Border: 0.5px solid #F59E0B
Padding: 12px 14px
Border-radius: 6px
Título: 11px / 500 / color #92400E (con ícono ⚠)
Texto: 10px / 400 / line-height 1.6 / color #78350F
```
### 2.5 Tabla comparativa
```
Header row:
Fondo: #FFF8ED
Border-bottom: 1px solid #F59845
Padding: 8px 6px
Texto: 10px / 500 / color #92400E
Body row (default):
Fondo: white
Border-bottom: 0.5px solid #E2E8F0
Padding: 8px 6px
Texto: 10px / 400 / color #0F172A
Números: font-family mono
Body row (no automatizable):
Fondo: #F8FAFC
Texto: color #64748B (más tenue)
Total row:
Fondo: #FFF8ED
Border-top: 1.5px solid #F59845
Texto: 10px / 500 / color #0F172A
```
### 2.6 Header de página PDF
```
Layout: flex justify-between
Border-bottom: 3px solid #F59845 (página 1) / 2px (otras páginas)
Padding-bottom: 10-12px
Margin-bottom: 14-16px
Izquierda:
Logo "InQ ROI": 18-22px / 700 / #F59845
Tagline (página 1): 9px / letter-spacing 1.5px / #94A3B8
Derecha:
Label "UN PRODUCTO DE": 9px / letter-spacing 1px / #94A3B8
"InQ": 16px / 700 / #475569
"InQuality": 9px / #64748B
```
### 2.7 Footer de página PDF
```
Position: absolute bottom 14-16px
Padding-top: 6-8px
Border-top: 0.5px solid #E2E8F0
Layout: flex justify-between
Font: 9px / 400 / #94A3B8
Izquierda:
"InQ ROI" en bold #F59845
" · Powered by InQuality · "
Nombre cliente · fecha
Derecha:
"Página X de Y"
```
### 2.8 Badge "Automatizable" (⚡)
#### En canvas BPMN (overlay)
```
Tamaño: 14px diameter
Fondo: #F59845
Border: 1.5px solid white
Posición: top: -8, left: nodeWidth - 12 (NO usar right por compatibilidad bpmn-js v18)
Símbolo ⚡: white, 8px, font-weight 700
```
#### En tabla
```
Tamaño: 14px diameter inline
Fondo: #F59845
Color: white
```
#### En leyenda
```
Tamaño: 14px diameter
Mismo estilo que en tabla
```
---
## 3. Rediseño de PDFs
### 3.1 Decisión arquitectónica: orientación híbrida
Los PDFs con diagrama BPMN tendrán **layout híbrido portrait + landscape**:
- **Páginas SIN diagrama:** vertical A4 (portrait)
- **Página DEL diagrama BPMN:** horizontal A4 (landscape) — para que el diagrama tenga espacio real
Esto es bloqueante: jsPDF soporta página por página con orientación individual. Implementar usando `doc.addPage([width, height], 'landscape')` para la página del diagrama.
### 3.2 PDF de Comparación + ROI (4 páginas)
#### Página 1 — Resumen ejecutivo (vertical)
Reemplaza la carátula. Header denso que incluye toda la metadata.
```
Header (con border-bottom 3px solid #F59845):
- Logo InQ ROI a la izquierda
- "UN PRODUCTO DE / InQ / InQuality" a la derecha
Strip de metadata (grid 2fr 1fr 1fr en fondo #FAFBFC):
- Proceso: Originación de créditos personales
- Cliente: Banco Atlántico S.A.
- Fecha: 15 mayo 2026
Banner narrativo (#FFF8ED con border-left naranja):
Resumen ejecutivo: Automatización de N actividades sobre proceso de M etapas.
Frecuencia X/año, horizonte Y años, inversión USD Z.
KPI Hero (gradiente naranja → magenta):
AHORRO PROYECTADO A 3 AÑOS
USD 192.400 — neto de inversión
Recuperás 2,4× la inversión en el período
KPIs secundarios (grid 2x2):
- AHORRO POR EJECUCIÓN: USD 18,16 — ↓ 38%
- AHORRO ANUAL: USD 90.800
- PAYBACK: 10,6 meses
- ROI ANUAL: 114%
Top 3 ahorros (caja con border #E2E8F0):
Header: "[3] Top actividades con mayor ahorro"
3 barras horizontales con %, monto, label
Trayectoria del ahorro (caja con border #E2E8F0):
Gráfico SVG nativo en jsPDF con:
- Área negativa (antes del payback) en #FEE2E2
- Área positiva (después) en #FED7AA
- Línea principal #F59845
- Marker vertical en mes de payback
- Labels eje X: 0 / Año 1 / Año 2 / Año 3
- Valor final en esquina superior derecha
IMPORTANTE: dibujado con jsPDF nativo, NO captura html2canvas
(los charts ECharts en imagen quedan borrosos, lo verificamos en MVP)
Footer (absolute bottom):
"InQ ROI · Powered by InQuality · Banco Atlántico · 15 may 2026"
"Página 1 de 4"
```
#### Página 2 — Diagrama BPMN (horizontal)
```
Orientación: landscape A4
Header (border-bottom 2px solid #F59845):
- Logo InQ ROI + título "MAPA DE CALOR · ESCENARIO ACTUAL"
- "Originación de créditos personales"
- Costo total a la derecha
Canvas del diagrama:
- Background #FAFBFC
- Border 0.5px #E2E8F0
- Border-radius 6px
- Padding 18px
- Altura ~400px (toma casi toda la página)
- El diagrama BPMN ocupa TODO el ancho disponible
- Embebido como JPEG vía html2canvas
- Badge ⚡ #F59845 sobre los nodos automatizables
Leyenda al pie:
- Heatmap gradient: bajo → alto
- Badge ⚡: marcado como automatizable
Footer (modificado para landscape):
Mismo formato pero ajustado al ancho horizontal
```
#### Página 3 — Comparación por actividad (vertical)
```
Header:
- Logo InQ ROI
- "COMPARACIÓN ACTIVIDAD POR ACTIVIDAD"
- "Detalle de costos: actual vs. automatizado"
Texto introductorio:
"Las actividades marcadas con [⚡] son candidatas a automatización..."
Tabla autotable (jsPDF-autotable):
Columnas: Actividad | Costo actual | Costo auto. | Ahorro | % | Prob.
Header con fondo #FFF8ED y border-bottom #F59845
Filas automatizables: fondo blanco
Filas no-automatizables: fondo #F8FAFC, texto #64748B
Total row: fondo #FFF8ED, border-top 1.5px #F59845
Nota:
"Tabla muestra X de Y actividades..."
Footer estándar
```
#### Página 4 — Análisis y metodología (vertical)
```
Header:
"CONTEXTO DEL ANÁLISIS"
"Composición del ahorro y metodología"
Sección: Origen del ahorro
Texto: "El N% del ahorro proyectado proviene de la automatización de..."
3 barras horizontales con el desglose
Sección: Metodología de cálculo
Caja gris (#F8FAFC):
- Costo por ejecución (fórmula)
- Ahorro anual (fórmula)
- Payback (fórmula)
- ROI anualizado (fórmula)
Sección: Supuestos del análisis
Caja ámbar (warning):
Texto sobre limitaciones y supuestos
Sección: Transparencia metodológica (CONDICIONAL)
Solo si TransparencySection detecta casos (los 4 ya implementados):
- Caso 1: actividades automatizables con costo cero
- Caso 2: inversión cero
- Caso 3: ROI > 1000%
- Caso 4: payback < 1 mes con inversión 0
Mismo estilo de caja ámbar
Sección: Próximos pasos sugeridos (opcional, hardcoded por ahora)
3-4 puntos de validación recomendada
Footer estándar
```
### 3.3 PDFs de Actual y Automatizado (3 páginas cada uno)
Los PDFs por escenario individual mantienen estructura similar pero adaptada:
```
Página 1 (vertical):
Header
Strip de metadata
KPIs principales (Costo total, Costo directo/Overhead, Tiempo, Actividades)
Top 3 actividades por costo
Footer
Página 2 (horizontal):
Diagrama BPMN con heatmap embebido
Leyenda
Página 3 (vertical):
Tabla de costos por actividad
Nota metodológica
Footer
```
### 3.4 Formato de Payback (regla global)
Implementar en un helper compartido entre web y PDF:
```typescript
function formatPayback(months: number): string {
if (Number.isNaN(months) || !isFinite(months)) return "No recupera en horizonte";
if (months < 0) return "Sin payback (costo aumenta)";
if (months === 0) return "Inmediato";
if (months < 0.1) return "< 1 día";
if (months < 1) return `< 1 mes (${months.toFixed(2)} meses)`;
if (months < 12) return `${months.toFixed(1)} meses`;
if (months < 24) {
const years = (months / 12).toFixed(1);
return `${months.toFixed(1)} meses (~${years} años)`;
}
return `${(months / 12).toFixed(1)} años`;
}
```
Tests:
- 0 → "Inmediato"
- 0.05 → "< 1 día"
- 0.5 → "< 1 mes (0.50 meses)"
- 6 → "6.0 meses"
- 15 → "15.0 meses (~1.3 años)"
- 30 → "2.5 años"
- Infinity → "No recupera en horizonte"
- -1 → "Sin payback (costo aumenta)"
- NaN → "No recupera en horizonte"
- 11.95 → "11.9 meses"
- 12.0 → "12.0 meses (~1.0 años)"
---
## 4. Rediseño Web App
### 4.1 Topbar global
```
Background: white
Border-bottom: 1px solid #E2E8F0
Padding: 12px 20px
Layout: flex justify-between
Izquierda:
Logo "InQ ROI" 18px / 700 / #F59845
Spacer 24px
Nav links:
- Workspace (activo: fondo #FFF8ED, texto #F59845, font-weight 500)
- Reporte
- Recursos
Cada link: padding 6px 12px, border-radius 4px
Derecha:
Botón secundario "Exportar PDF" (white, border #E2E8F0)
Botón primario "Simular →" (background #F59845, color white, font-weight 700)
```
### 4.2 Workspace (BpmnCanvas + ActivityPanel)
```
Layout: flex, height calc(100vh - topbar)
Canvas izquierdo (flex 1):
Background: #FAFBFC
Padding: 20px
Border-right: 1px solid #E2E8F0
Diagrama BPMN:
- Estilos por defecto de bpmn-js
- Actividad seleccionada: fondo #FFF8ED, border 2px #F59845
- Badges ⚡ en automatizables: círculo #F59845, ⚡ blanco
- Posición badge: top: -8, left: width - 12
Leyenda flotante (bottom-left):
Background white, border 0.5px #E2E8F0, radius 6px
Padding 8px 12px
Contenido: "N actividades automatizables" con icono
Panel derecho (width 280-320px):
Background: white
Padding: 16px 18px
Overflow: auto
Header del panel:
Badge ⚡ + nombre actividad + "Actividad seleccionada"
Border-bottom 1px #E2E8F0
Padding-bottom 12px
Sección "COSTO ACTUAL":
Label 10px / 500 / letter-spacing 1px / #94A3B8
Inputs: border #E2E8F0, padding 6px 10px, bg #F8FAFC
Sección "AUTOMATIZACIÓN" (caja destacada):
Background: #FFF8ED
Border: 1px solid #F59845
Padding: 12px
Border-radius: 6px
Header con título y switch:
"AUTOMATIZACIÓN" 11px / 500 / #92400E
Switch: 30x16px, background #F59845 cuando ON
Inputs:
border #F59845, background white, color #0F172A
Helper de ahorro estimado (cuando aplica):
Background: #F0FDF4
Border-left: 2px solid #16A34A
Padding: 8px 10px
Texto: 10px / color #15803D
Texto: "Ahorro estimado: USD X por ejecución (Y%)"
```
### 4.3 Reporte ROI (tab Comparación + ROI)
```
Page header:
Título "Nombre del proceso"
Subtítulo "Cliente · Simulado el ..."
Tabs:
3 tabs horizontales con border-bottom-color en activo
Activo: border-bottom 2px #F59845, texto #F59845 / 500
Inactivo: texto #64748B
Contenido tab "Comparación + ROI":
KPI Hero:
Mismo estilo que PDF — gradient naranja → magenta
Margin-bottom 12px
KPIs secundarios (grid 4 columnas):
Mismo estilo que PDF
Gap 8px
Gráficos (grid 2 columnas):
Trayectoria del ahorro: chart con líneas y marker
Top 3 ahorros: barras horizontales
Tabla comparativa completa abajo:
Misma estructura que PDF Pág. 3
Sortable por columna ahorro (mantener funcionalidad existente)
Sección Transparencia (condicional):
Misma estructura que PDF, banner ámbar
```
### 4.4 Componentes shadcn a personalizar
Los componentes shadcn que ya existen necesitan tema con los tokens nuevos:
- `Button` variant "default" → fondo `#F59845`, hover `#B45309`, font-weight 700
- `Button` variant "secondary" → border `#E2E8F0`, hover bg `#F8FAFC`
- `Tabs` → active state con color naranja en lugar de azul
- `Switch` → naranja cuando checked
- `Badge` → variantes "automatable" con fondo naranja
---
## 5. Cambios funcionales asociados
### 5.1 Helper `formatPayback`
Crear en `src/lib/format.ts` (o donde ya estén los formatters de moneda).
Tests obligatorios — ver sección 3.4.
Aplicar en:
- `RoiKpiBar.tsx` (web)
- `pdf-sections-roi.ts → drawRoiKpiCards`
- `csv-export.ts` línea de metadata
### 5.2 Detector de ROI inusual ya implementado en Etapa 3 de Sprint 1
Verificar que la sección Transparencia se renderiza correctamente en el PDF (Sprint 1 Etapa 4 tuvo PDF de 36KB que era anómalo — revisar que ahora con el rediseño tenga densidad razonable).
### 5.3 Overlay badge en BpmnCanvas
El badge ⚡ ya está implementado. Cambios visuales:
- Fondo de blanco a `#F59845` (más prominente)
- ⚡ blanco en lugar de ámbar
- Posición: cambiar de `{ top: -8, right: -8 }` a `{ top: -8, left: elementWidth - 12 }` para no depender del soporte de `right` en bpmn-js v18
### 5.4 Carátula del PDF — eliminar
El PDF anterior tenía una carátula con gradient grande. Reemplazar por header denso en página 1 (ver sección 3.2 — Página 1).
---
## 6. Validación E2E
### 6.1 Archivos a generar
Para validar el rediseño, generar los siguientes archivos en `tests/e2e/__output__/`:
**Setup A: medium-with-gateways realista**
- 3 actividades automatizables (`task_recv`, `task_score`, `task_analisis`)
- Costos directos: $200/actividad
- Costos automatizados: $20/actividad
- Frecuencia anual: 5.000
- Horizonte: 3 años
- Inversión: USD 80.000
Generar:
- `medium-gw_actual_v2.pdf`
- `medium-gw_automatizado_v2.pdf`
- `medium-gw_roi_v2.pdf`
**Setup B: medium-with-gateways con ROI inusual**
- Mismas actividades automatizables
- Costos automatizados: $1 (para forzar ROI > 1000%)
- Frecuencia anual: 12.000
- Inversión: USD 10.000
Generar:
- `medium-gw_roi_inusual_v2.pdf` — debe mostrar Transparencia Metodológica con aviso de ROI inusual
**Setup C: payback en distintos rangos**
- `payback_inmediato.pdf` — inversión = 0
- `payback_corto.pdf` — payback de 0.5 meses
- `payback_normal.pdf` — payback de 10 meses
- `payback_largo.pdf` — payback de 30 meses
Generar:
- `payback_formats.pdf` — uno solo con los 4 casos para verificar el formato visual
### 6.2 Métricas que Claude Code DEBE reportar
Tabla de validación:
```
┌────────────────────────────┬──────────┬──────────┬───────────────────┐
│ Archivo │ Tamaño │ Páginas │ Validación visual │
├────────────────────────────┼──────────┼──────────┼───────────────────┤
│ medium-gw_actual_v2.pdf │ XX KB │ 3 │ ✓ / ✗ │
│ medium-gw_automatizado_v2 │ XX KB │ 3 │ ✓ / ✗ │
│ medium-gw_roi_v2.pdf │ XX KB │ 4 │ ✓ / ✗ │
│ medium-gw_roi_inusual_v2 │ XX KB │ 4 │ ✓ / ✗ │
└────────────────────────────┴──────────┴──────────┴───────────────────┘
```
**Tamaño esperado por página: 25-50 KB.** Páginas con diagrama BPMN embebido pueden llegar a 60 KB. Si alguna página queda en < 15 KB es señal de que algo no se renderizó.
Análisis de píxeles del heatmap embebido (igual que MVP):
- Detectar verde, amarillo y rojo en la página del diagrama
- Verificar que el badge ⚡ naranja `#F59845` está presente (al menos un pixel naranja sobre el diagrama)
Text extraction con pdf-parse para verificar palabras clave:
- `_actual.pdf`: "Costo total esperado", "Análisis de procesos"
- `_automatizado.pdf`: "Costo total", "actividades no automatizables"
- `_roi.pdf`: "Análisis de retorno", "Ahorro anual", "Payback", "ROI"
- `_roi_inusual.pdf`: TODO lo anterior + "ROI inusualmente alto detectado"
Verificación de marca:
- En cada PDF, buscar texto exacto "InQ" (con I-n-Q correctos)
- Verificar que NO aparece "INQ", "Inq", "inq" en ningún PDF
- Verificar "Powered by InQuality" en cada footer
### 6.3 Validación visual obligatoria por el usuario
Después de generar los archivos, presentarlos vía `present_files` para que el usuario abra los PDFs en Preview y valide:
1. ¿Los KPIs se ven como cards prominentes con el gradiente naranja → magenta?
2. ¿El diagrama BPMN se ve grande y legible en la página horizontal?
3. ¿El formato de payback es claro ("10.6 meses" vs "0.0 meses")?
4. ¿La sección Transparencia metodológica aparece cuando corresponde?
5. ¿"InQ ROI" y "Powered by InQuality" se ven correctos en headers/footers?
6. ¿La densidad de información es adecuada (no hay páginas semivacías)?
---
## 7. Definition of Done
- [ ] Manual de marca aplicado: paleta naranja InQ, tipografía consistente, "InQ" siempre I-n-Q
- [ ] PDF de Comparación + ROI con layout híbrido (vertical + horizontal + vertical + vertical)
- [ ] PDF de Actual y Automatizado con layout híbrido (vertical + horizontal + vertical)
- [ ] Página 1 de cada PDF SIN carátula, con header denso que incluye metadata
- [ ] KPI Hero con gradiente naranja → magenta en PDF y Web
- [ ] Top 3 ahorros visible en página 1 del PDF ROI
- [ ] Tabla comparativa con paleta naranja consistente
- [ ] Footer real al pie de página (no a mitad)
- [ ] Helper `formatPayback` implementado y aplicado en UI, PDF y CSV
- [ ] Badge ⚡ en BpmnCanvas con fondo naranja sólido `#F59845`
- [ ] Posición del badge cambiada a `{ top, left }` (no `right`) — robusto a bpmn-js v18
- [ ] Web app con identidad: topbar con logo, CTAs naranjas, tabs con color de marca
- [ ] Panel de Automatización en ActivityPanel con caja naranja destacada
- [ ] 7 archivos de validación generados en `tests/e2e/__output__/`
- [ ] Análisis de píxeles confirma badge naranja + heatmap completo
- [ ] Verificación textual confirma "InQ" correcto en todos los PDFs
- [ ] `present_files` ejecutado para que el usuario abra los PDFs
- [ ] 440 tests previos siguen verdes
- [ ] Nuevos tests de `formatPayback` (≥12 casos)
- [ ] Build limpio, lint limpio
- [ ] CHANGELOG.md actualizado con sección "Sprint 1.5 — Rediseño visual"
- [ ] DESIGN_SYSTEM.md creado con paleta, tipografía y componentes (referencia para Sprint 2+)
---
## 8. Lo que NO entra en Sprint 1.5
- Refinamiento del ícono ⚡ a Bot de Lucide (Sprint 2-3)
- Nueva entidad `ProcessOverheadCost` para multas/penalidades (Sprint 2-3)
- Branding completo con logo SVG de InQuality embebido (Sprint 3-4)
- Excel multi-hoja con identidad (Sprint 3-4)
- Carátula opcional vía toggle de export (no se necesita por ahora — decidimos no usarla)
- Animaciones avanzadas en web app (Sprint 4+)
- Modo oscuro (Sprint 4+)
---
## 9. Cambios en archivos clave
### Nuevos
- `src/lib/format.ts` (o extender el existente) — `formatPayback`
- `DESIGN_SYSTEM.md` — referencia visual del sistema
- `src/lib/export/pdf-sections-shared.ts` — header y footer reutilizables
- `tests/lib/format-payback.test.ts` — tests del helper
### Modificados
- `src/lib/export/pdf-export.ts` — soportar orientación landscape en una página
- `src/lib/export/pdf-sections.ts` — aplicar nuevo manual de marca
- `src/lib/export/pdf-sections-roi.ts` — rediseño completo del PDF ROI
- `src/features/workspace/BpmnCanvas.tsx` — badge ⚡ naranja + posición
- `src/features/workspace/ActivityPanel.tsx` — sección Automatización con caja naranja
- `src/features/report/RoiKpiBar.tsx` — KPI Hero gradiente + secundarios paleta
- `src/features/report/ComparisonTable.tsx` — paleta naranja
- `src/features/report/TransparencySection.tsx` — verificar render correcto
- `src/components/ui/*` — tokens naranja en components shadcn (button, tabs, switch)
- `tailwind.config.ts` — extender paleta con tokens InQ
- `index.css` — definir variables CSS
### En CLAUDE.md, agregar regla crítica
```
## Reglas de marca (NO NEGOCIABLES)
1. La versión corta de InQuality SIEMPRE se escribe "InQ":
- I mayúscula
- n minúscula
- Q mayúscula
- Cualquier otra forma (INQ, Inq, inq, inQ) es bug
2. El producto se llama "InQ ROI" en este Sprint. Es nombre de trabajo,
se reemplazará en Sprint 3-4. No debatir el nombre ahora.
3. Color protagonista: naranja InQ #F59845.
Gradiente identidad: #F59845 → #ED3E8F (naranja → magenta).
No usar morado de CONCILIA (#572FA2, #CB1889) en este producto.
4. Tagline obligatorio en footers de PDF: "Powered by InQuality".
5. No promover a Marcos como creador del producto. Si aparece nombre del
consultor en algún campo, es como "consultor responsable", no como
"creador" o "founder".
```
---
## 10. Orden de implementación (11 etapas)
Cada etapa es validable independientemente. Pattern: implementación → validación visual con usuario → siguiente etapa.
1. **Etapa 1 — Setup del sistema de diseño** ✅ COMPLETADA
- DESIGN_SYSTEM.md, tailwind.config.ts, index.css
- Tokens InQ + heatmap
- Audit de azules y hardcodes
- Tests de tokens
2. **Etapa 2 — Helper formatPayback + aplicación**
- Helper en src/lib/format.ts (12 tests)
- Aplicación en RoiKpiBar, PDF, CSV
- Actualización del audit con WCAG y donut
3. **Etapa 3 — Web App: workspace + reporte**
- Topbar con logo InQ ROI
- ActivityPanel con sección Automatización naranja
- Reporte con KPI Hero gradiente y secundarios paleta
- font-weight 700 en CTAs primarios (WCAG)
- Validación visual con usuario
4. **Etapa 4 — PDF: header y footer compartidos**
- Crear `pdf-sections-shared.ts`
- Refactorizar PDFs existentes para usar header/footer comunes
- Aplicar paleta InQ
5. **Etapa 5 — PDF: layout híbrido (vertical + horizontal)**
- Modificar `pdf-export.ts` para soportar landscape en página específica
- Implementar página del diagrama horizontal
- Generar primer PDF de validación
6. **Etapa 6 — PDF: rediseño completo de página 1 ROI**
- Header denso sin carátula
- KPI Hero, secundarios, top 3, gráfico de trayectoria
- Aplicar `formatPayback`
- Generar segundo PDF de validación
7. **Etapa 7 — PDF: páginas 3 y 4 ROI**
- Tabla comparativa con paleta
- Análisis textual con secciones
- Sección Transparencia condicional
- Generar tercer PDF de validación
8. **Etapa 8 — PDFs de Actual y Automatizado**
- Aplicar mismo sistema
- Verificar consistencia visual
9. **Etapa 9 — Badge ⚡ en BpmnCanvas**
- Color naranja sólido
- Posición robusta (top + left)
- Test del overlay
10. **Etapa 10 — Validación E2E final**
- Generar los 7 archivos especificados
- Análisis de píxeles, text extraction
- `present_files` para validación visual del usuario
11. **Etapa 11 — Documentación + Deploy**
- CHANGELOG.md
- Actualizar CLAUDE.md con reglas de marca
- Tag de release v0.2.0-sprint1-visual
---
## Notas finales para Claude Code
- Este es un sprint de POLISH, no de features. Si encontrás algo que requiere cambios arquitectónicos importantes (más de 1 hora), PARÁ y consultá con el usuario antes de proceder.
- La validación visual final la hace el usuario abriendo los PDFs en Preview de Mac. Tu trabajo NO es solo generar archivos — es generar archivos QUE SE VEAN COMO EL MOCKUP MUESTRA. No declares la etapa terminada sin haber verificado tamaño, páginas, análisis de píxeles del heatmap, y palabras clave del texto extraído.
- Cada PDF generado pasa por `present_files` para que el usuario lo abra. NO te conformes con "los tests pasaron".
- Si en algún momento de la implementación descubrís que el mockup tiene una inconsistencia o un detalle no especificado, hacé las preguntas explícitamente antes de inventar la respuesta.
- El usuario ha sido riguroso pidiendo validación honesta. Mantené ese estándar: reportá lo que efectivamente verificaste, no lo que asumiste.

View File

@@ -0,0 +1,220 @@
# Sprint 1.5 — Etapa 1: Setup del sistema de diseño
> **Estado: COMPLETADA y APROBADA** (16 mayo 2026)
>
> Este documento es el prompt original que se ejecutó. Se preserva como referencia histórica del Sprint 1.5. La evidencia de ejecución vive en:
> - Código del repo (tokens InQ aplicados)
> - `sprints/sprint-1-5/etapa-1-audit.md` (audit de azules y hardcodes)
> - Tests en `tests/design-system/tokens.test.ts`
---
## Objetivo
Establecer la infraestructura visual del Sprint 1.5 antes de tocar cualquier componente de UI o PDF. Sin esto, los cambios de Etapas 2-9 quedarían hardcodeados con hexcodes dispersos.
---
## Plan aprobado con 4 ajustes
### Ajuste 1 — Heatmap: tokens SÍ, aplicación NO
Mantener los colores saturados actuales del heatmap (verde/ámbar/rojo). NO migrar a la paleta pastel del brief en esta etapa.
Razones:
- Romper los E2E de análisis de píxeles calibrados a colores saturados sería retroceso. Esos tests fueron el mecanismo de validación más sólido en Sprint 1.
- El producto se usa en talleres y charlas en pantallas grandes. El pastel se ve lavado a distancia.
Acciones:
- En `tailwind.config.ts`, definir tokens del heatmap saturado actual:
```typescript
heatmap: {
low: '#10B981',
mid: '#F59E0B',
high: '#EF4444',
}
```
- Reemplazar los hex hardcodeados de `HeatmapCanvas` por estos tokens.
- En `DESIGN_SYSTEM.md`, documentar la decisión.
- En `TODO.md`: anotar revisión del heatmap para Sprint 3-4.
### Ajuste 2 — Audit de azules residuales y hardcodes
Antes de finalizar Etapa 1, ejecutar auditoría:
```bash
grep -rn "blue\|sky\|indigo\|3B82F6\|2563EB\|1D4ED8" src/ \
--include="*.tsx" --include="*.ts" --include="*.css"
grep -rn "primary" src/ --include="*.tsx" --include="*.ts"
grep -rn "#[0-9a-fA-F]\{6\}" src/ --include="*.tsx" --include="*.ts"
```
Clasificar cada hallazgo en 3 categorías:
- "Migrar a token InQ"
- "Mantener azul funcional"
- "Migrar a neutral"
Documentar en `sprints/sprint-1-5/etapa-1-audit.md` con archivo:línea, color, clasificación, acción sugerida. NO resolver en Etapa 1; solo listar.
### Ajuste 3 — Tests del sistema de diseño
Crear `tests/design-system/tokens.test.ts` con al menos:
```typescript
describe('Design tokens', () => {
it('expone tokens de marca InQ vía Tailwind config', () => {
expect(config.theme.extend.colors.inq.orange.DEFAULT).toBe('#F59845');
expect(config.theme.extend.colors.inq.orange.dark).toBe('#B45309');
expect(config.theme.extend.colors.inq.orange.light).toBe('#FEF3E2');
expect(config.theme.extend.colors.inq.orange.lighter).toBe('#FFF8ED');
expect(config.theme.extend.colors.inq.magenta).toBe('#ED3E8F');
});
it('NO incluye los colores de CONCILIA', () => {
const allColors = JSON.stringify(config.theme.extend.colors);
expect(allColors).not.toContain('#572FA2');
expect(allColors).not.toContain('#CB1889');
});
it('expone tokens del heatmap saturado', () => {
expect(config.theme.extend.colors.heatmap.low).toBe('#10B981');
expect(config.theme.extend.colors.heatmap.mid).toBe('#F59E0B');
expect(config.theme.extend.colors.heatmap.high).toBe('#EF4444');
});
});
```
Adicionalmente, crear `tests/lib/format-payback.test.ts` con 7 `it.todo` que preparan Etapa 2.
### Ajuste 4 — Estructura anidada en Tailwind config
Estructura anidada (no con guiones en clases):
```typescript
inq: {
orange: {
DEFAULT: '#F59845',
dark: '#B45309',
light: '#FEF3E2',
lighter: '#FFF8ED',
},
magenta: '#ED3E8F',
},
heatmap: {
low: '#10B981',
mid: '#F59E0B',
high: '#EF4444',
}
```
Permite uso natural:
- `bg-inq-orange` (DEFAULT)
- `bg-inq-orange-dark`
- `bg-inq-orange-light`
- `bg-inq-orange-lighter`
- `bg-inq-magenta`
- `bg-heatmap-low`
Colores semánticos:
```typescript
success: { DEFAULT: '#16A34A', light: '#F0FDF4' },
warning: { DEFAULT: '#F59E0B', bg: '#FFFBEB' },
```
---
## Checklist final de Etapa 1
```
[x] DESIGN_SYSTEM.md creado con sección de decisión sobre heatmap
[x] tailwind.config.ts con tokens InQ anidados + tokens heatmap saturado
[x] src/index.css con --primary naranja y variables InQ
[x] HeatmapCanvas migrado a tokens heatmap.* (find-and-replace simple)
[x] CLAUDE.md con sección "Reglas de marca" al inicio (prominente)
[x] tests/design-system/tokens.test.ts con 3 tests verdes
[x] tests/lib/format-payback.test.ts con 7 it.todo
[x] sprints/sprint-1-5/etapa-1-audit.md con hallazgos (sin resolver)
[x] TODO.md actualizado con nota de revisión de heatmap para Sprint 3-4
[x] 440 tests previos + 3 nuevos = 443 verdes
[x] npm run build limpio
[x] npm run dev levanta sin errores
[x] Verificación visual con dev server: screenshots de CTAs naranjas,
tabs activos naranjas, switches checked naranjas
```
---
## Reporte de ejecución (resumen)
### Archivos modificados/creados
| Archivo | Acción | Líneas |
|---|---|---|
| `tailwind.config.js` | Modificado | +25 (tokens inq.*, heatmap.*, success, warning) |
| `src/styles/globals.css` | Modificado | +22 (--primary naranja, variables --inq-*) |
| `src/components/ui/tabs.tsx` | Modificado | 1 (text-inq-orange + font-semibold en activo) |
| `CLAUDE.md` | Modificado | +42 (sección "Reglas de Marca" prominente) |
| `TODO.md` | Modificado | +3 (entrada revisión heatmap Sprint 3-4) |
| `DESIGN_SYSTEM.md` | Creado | 232 |
| `sprints/sprint-1-5/etapa-1-audit.md` | Creado | 80 |
| `tests/design-system/tokens.test.ts` | Creado | 37 (3 tests) |
| `tests/lib/format-payback.test.ts` | Creado | 18 (7 it.todo) |
| `tests/e2e/screenshots-etapa-1.spec.ts` | Creado | 60 |
| `tests/screenshots/etapa-1/*.png` | Generados | 3 |
### Hallazgos del audit (9 azules + 12 hardcodes)
Categorizados por etapa de resolución:
- **Etapa 3 (charts web):** `CumulativeSavingsChart.tsx`, `TopSavingsChart.tsx`, `HeatmapLegend.tsx`, `chart-options.ts`
- **Etapa 4-8 (PDFs):** `pdf-sections.ts`, `pdf-export.ts`, `pdf-sections-roi.ts` (`PDF.blue`)
- **Etapa 9 (badge ⚡):** `BpmnCanvas.tsx`
- **NO migrar (decisión arquitectónica):** `GatewayPanel.tsx` (azul = semántica funcional info banner)
### Validación visual confirmada
3 screenshots en `tests/screenshots/etapa-1/`:
- `workspace.png` — botón Simular naranja, tab Actividad activo naranja, borde del nodo seleccionado naranja
- `report-tabs.png` — tab Actual activo naranja, Exportar PDF naranja, KPI icons naranjas
- `switch-on.png` — switch automatizable ON naranja, sección automatización con caja naranja, Guardar cambios naranja
El cambio de `--primary` de azul a naranja propagó correctamente a: Button, Switch, Tabs, Slider, Tooltip, Badge, rings de focus, bordes de selección en canvas BPMN.
### Descubrimientos no esperados
- `--primary-foreground` (210 40% 98%, blanco casi puro) funciona correctamente sobre el naranja sin modificación.
- `--ring` también está en naranja, los focus rings de inputs y botones son coherentes con la marca.
### Tests finales
- 443 tests verdes (440 previos + 3 nuevos de tokens)
- 7 `it.todo` listos para Etapa 2 (formatPayback)
- Build limpio, sin warnings TS ni lint nuevos
---
## Aprobación final del usuario
Confirmada con observaciones que se trasladan a Etapa 2:
1. La fila de `GatewayPanel.tsx` en el audit se reclasifica de "Mantener azul funcional / Evaluar Sprint 2" a "Decisión arquitectónica: mantener azul informativo. No es marca, es semántica funcional."
2. El donut chart (`chart-options.ts:123`) tiene una decisión visual pendiente: sector "directo" en naranja InQ, sector "overhead" en slate-400 o naranja claro (no en otro naranja). Decisión final en Etapa 3.
3. Contraste WCAG del naranja `#F59845` sobre blanco: ratio ~2.42:1, FAIL para AA normal y AA large. Decisión propuesta para Etapa 3: aplicar `font-weight: 700` en CTAs primarios (opción D del análisis).
Estas 3 observaciones se incorporan en Etapa 2 como pre-tareas.
---
## Lecciones operativas capturadas
1. **Pattern de auditoría preventiva**: el grep de azules y hex residuales antes de mergear cambios de paleta previno que la migración a InQ quedara incompleta. Replicar este pattern en cualquier cambio de paleta futuro.
2. **Validación visual con screenshots Playwright**: más robusto que pedirle al usuario que capture manualmente. Los PNG quedan versionados en `tests/screenshots/etapa-*/` como evidencia de la etapa.
3. **Decisiones arquitectónicas vs decisiones de marca**: `GatewayPanel` enseñó que no todo color se debe alinear con la marca. El azul informativo es una convención semántica funcional que vale la pena preservar.
4. **El audit que clasifica por etapa de resolución** evita el "tendremos que migrar todo de golpe". Tokenizar primero, aplicar después en pequeños lotes.

View File

@@ -0,0 +1,213 @@
GO — Etapa 2: Helper formatPayback + aplicación
Tu plan original tenía 7 it.todo listos. Esta etapa los convierte en
tests reales, implementa el helper y lo aplica en 3 lugares.
═══════════════════════════════════════════════════════════════════════
PRE-TAREAS (actualizar artefactos de Etapa 1)
═══════════════════════════════════════════════════════════════════════
Antes de implementar formatPayback, hacé estas 3 actualizaciones rápidas:
1. Actualizar etapa-1-audit.md — fila de GatewayPanel.tsx:
Cambiar la categoría de las 5 referencias blue-* de GatewayPanel de
"Mantener azul funcional / Evaluar en Sprint 2" a:
"Decisión arquitectónica: mantener azul informativo. No es color
de marca, es semántica funcional (info banner, valor inmutable,
patrón Bootstrap/Tailwind estándar). NO migrar."
2. Actualizar etapa-1-audit.md — agregar fila al final de la sección
"Hallazgos — Hex hardcodes" para el donut chart:
| chart-options.ts:123 | #3b82f6 → naranja | Composición directo/overhead
| en donut. Decisión visual: el sector "directo" va en naranja InQ
| #F59845. El sector "overhead" NO debe ser otro naranja (pierde
| contraste) — usar slate-400 (#94A3B8) o naranja muy claro
| (#FED7AA). Decisión final en Etapa 3 mirando el render. |
3. Verificar contraste WCAG de #F59845 sobre blanco. Ejecutá:
node -e "
const hex = '#F59845';
const r = parseInt(hex.slice(1,3), 16) / 255;
const g = parseInt(hex.slice(3,5), 16) / 255;
const b = parseInt(hex.slice(5,7), 16) / 255;
const lin = (c) => c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
const L1 = 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b);
const L2 = 1;
const ratio = (L2 + 0.05) / (L1 + 0.05);
console.log('Contrast ratio:', ratio.toFixed(2), ':1');
console.log('AA normal text (4.5):', ratio >= 4.5 ? 'PASS' : 'FAIL');
console.log('AA large text (3.0):', ratio >= 3.0 ? 'PASS' : 'FAIL');
"
Anotá el resultado en etapa-1-audit.md como nueva sección
"Accesibilidad — Contraste de marca". Si el resultado es FAIL para
AA normal, agregá las 4 opciones de remediación al audit:
A) Aceptar contraste sub-WCAG en CTAs (riesgo accesibilidad)
B) Oscurecer naranja a #D97706 para CTAs (rompe marca parcialmente)
C) Usar texto oscuro #451A03 en CTAs sobre naranja (cambia look)
D) Forzar font-weight: 700 en CTAs primarios (cumple AA large)
Marcá la opción D como propuesta a implementar en Etapa 3 cuando
rediseñemos KPI cards y CTAs. NO modificar componentes en esta etapa.
═══════════════════════════════════════════════════════════════════════
DECISIONES YA TOMADAS (no consultar de nuevo)
═══════════════════════════════════════════════════════════════════════
Confirmadas con el usuario:
Payback negativo → "Sin payback (costo aumenta)"
(caso donde costo automatizado > costo actual, escenario
matemáticamente posible pero indica error de configuración o
automatización inviable)
Texto del formato → usar caracteres compactos "<" y "~"
(universales y legibles en KPI cards)
Pluralización → siempre plural incluso con decimales
("1.0 meses", no "1.0 mes")
═══════════════════════════════════════════════════════════════════════
TAREA PRINCIPAL — IMPLEMENTAR formatPayback
═══════════════════════════════════════════════════════════════════════
1. Implementar formatPayback en src/lib/format.ts:
function formatPayback(months: number): string {
if (Number.isNaN(months) || !isFinite(months)) {
return "No recupera en horizonte";
}
if (months < 0) {
return "Sin payback (costo aumenta)";
}
if (months === 0) {
return "Inmediato";
}
if (months < 0.1) {
return "< 1 día";
}
if (months < 1) {
return `< 1 mes (${months.toFixed(2)} meses)`;
}
if (months < 12) {
return `${months.toFixed(1)} meses`;
}
if (months < 24) {
const years = (months / 12).toFixed(1);
return `${months.toFixed(1)} meses (~${years} años)`;
}
return `${(months / 12).toFixed(1)} años`;
}
2. Convertir los 7 it.todo existentes en tests reales:
- formatPayback(0) === "Inmediato"
- formatPayback(0.05) === "< 1 día"
- formatPayback(0.5) === "< 1 mes (0.50 meses)"
- formatPayback(6) === "6.0 meses"
- formatPayback(15) === "15.0 meses (~1.3 años)"
- formatPayback(30) === "2.5 años"
- formatPayback(Infinity) === "No recupera en horizonte"
3. Agregar 5 tests de edge cases:
- formatPayback(-1) === "Sin payback (costo aumenta)"
// payback negativo: costo automatizado supera al actual
- formatPayback(-100) === "Sin payback (costo aumenta)"
// negativo grande: mismo comportamiento
- formatPayback(NaN) === "No recupera en horizonte"
// NaN cae en el primer guard
- formatPayback(11.95) === "11.9 meses"
// verificar que el borde con 12 no salta a "1.0 años" prematuramente
- formatPayback(12.0) === "12.0 meses (~1.0 años)"
// exactamente en el umbral
Total: 12 tests verdes en tests/lib/format-payback.test.ts
═══════════════════════════════════════════════════════════════════════
APLICACIÓN EN 3 LUGARES
═══════════════════════════════════════════════════════════════════════
a) RoiKpiBar.tsx (web app):
- Reemplazar el formato actual del KPI "PAYBACK" por formatPayback(months)
- El KPI card debe mostrar el string completo
- PROBLEMA POTENCIAL: strings largos como "15.0 meses (~1.3 años)"
pueden desbordar la card. Soluciones:
- Opción 1: reducir font-size del valor a 14px en el card de payback
(solo en ese card, no en los otros KPIs)
- Opción 2: usar 2 líneas — primera línea cifra principal, segunda
línea texto secundario en font-size menor
PROBÁ ambas y mostrame screenshot de cada una en Etapa 3. Por ahora
implementá la opción 1 (más simple, mantiene grilla consistente).
b) src/lib/export/pdf-sections-roi.ts → drawRoiKpiCards:
- Aplicar formatPayback en la KPI card del PDF
- Verificar ancho: el card actual mide ~120px en página A4. String
"15.0 meses (~1.3 años)" en font 16px puede no entrar.
Si es así, reducir font-size de esa card específica a 14px.
- Probar con paybacks de distintos rangos: 0.5, 6, 15, 30 meses
para ver el comportamiento.
c) src/lib/export/csv-export.ts:
- En la línea de metadata "# Payback (meses): X.XX" reemplazar el
número crudo por formatPayback formateado
- AGREGAR una segunda línea inmediatamente después:
"# Payback (meses, raw): X.XXXX"
Esto preserva compatibilidad con integraciones futuras de BI/Excel
que necesiten el valor numérico crudo.
d) BONUS — verificar TransparencySection:
El detector de ROI inusual (Etapa 3 de Sprint 1) menciona payback en
alguno de sus 4 casos. Verificá si el texto incluye payback formateado
o crudo. Si es crudo, aplicar formatPayback también ahí.
═══════════════════════════════════════════════════════════════════════
ENTREGABLES
═══════════════════════════════════════════════════════════════════════
[ ] etapa-1-audit.md actualizado con 3 correcciones (GatewayPanel,
donut chart, contraste WCAG)
[ ] formatPayback implementado en src/lib/format.ts
[ ] 12 tests verdes en tests/lib/format-payback.test.ts
[ ] formatPayback aplicado en RoiKpiBar.tsx
[ ] formatPayback aplicado en pdf-sections-roi.ts (drawRoiKpiCards)
[ ] formatPayback aplicado en csv-export.ts (con doble línea: formateado
+ raw para compatibilidad)
[ ] TransparencySection verificado y actualizado si aplica
[ ] 443 tests previos + 12 nuevos = 455+ tests verdes
[ ] npm run build limpio
[ ] Screenshot del RoiKpiBar mostrando un payback de 10.6 meses
(caso del PDF roi-realistic)
[ ] PDF de muestra generado con setup realista del brief:
- medium-with-gateways
- 3 automatizables, $200/$20 costos
- 5000 ejecuciones/año, 3 años, USD 80.000 inversión
- Resultado esperado: payback ~10.6 meses (NO "0.0 meses")
→ guardar como tests/e2e/__output__/payback-formatted-sample.pdf
═══════════════════════════════════════════════════════════════════════
REPORTE FINAL A PRESENTAR
═══════════════════════════════════════════════════════════════════════
1. Resultado del cálculo de contraste WCAG (PASS/FAIL para AA normal
y AA large)
2. Confirmación de las 3 actualizaciones del audit
3. Tabla de tests con resultados (12 verdes esperados)
4. Screenshot del RoiKpiBar con payback formateado
5. PDF de muestra presentado con present_files para que el usuario
abra en Preview y valide visualmente
6. Cantidad final de tests verdes
7. Cualquier desbordamiento de UI/PDF detectado y cómo se resolvió
NO arranques Etapa 3 sin mi OK explícito.
Go.

View File

@@ -21,10 +21,10 @@
| Archivo:línea | Clase actual | Categoría | Acción sugerida |
|---|---|---|---|
| `GatewayPanel.tsx:122` | `bg-blue-50 border-blue-200` | **Mantener azul funcional** | El azul en gateways AND paralelos es convención visual (flujo paralelo = azul). Evaluar en Sprint 2 si cambiar a slate-50 sin perder semántica. |
| `GatewayPanel.tsx:124` | `text-blue-500` | **Mantener azul funcional** | Ídem — ícono Info en bloque paralelo |
| `GatewayPanel.tsx:126-127` | `text-blue-800`, `text-blue-700` | **Mantener azul funcional** | Texto del bloque informativo de gateway AND |
| `GatewayPanel.tsx:144` | `text-blue-600` | **Mantener azul funcional** | "100%" en gateway paralelo — valor fijo, semántica coherente |
| `GatewayPanel.tsx:122` | `bg-blue-50 border-blue-200` | **Decisión arquitectónica: NO migrar** | Decisión arquitectónica: mantener azul informativo. No es color de marca, es semántica funcional (info banner, valor inmutable, patrón Bootstrap/Tailwind estándar). NO migrar. |
| `GatewayPanel.tsx:124` | `text-blue-500` | **Decisión arquitectónica: NO migrar** | Decisión arquitectónica: mantener azul informativo. No es color de marca, es semántica funcional (info banner, valor inmutable, patrón Bootstrap/Tailwind estándar). NO migrar. |
| `GatewayPanel.tsx:126-127` | `text-blue-800`, `text-blue-700` | **Decisión arquitectónica: NO migrar** | Decisión arquitectónica: mantener azul informativo. No es color de marca, es semántica funcional (info banner, valor inmutable, patrón Bootstrap/Tailwind estándar). NO migrar. |
| `GatewayPanel.tsx:144` | `text-blue-600` | **Decisión arquitectónica: NO migrar** | Decisión arquitectónica: mantener azul informativo. No es color de marca, es semántica funcional (info banner, valor inmutable, patrón Bootstrap/Tailwind estándar). NO migrar. |
| `ResourcesPanel.tsx:22` | `bg-blue-100 text-blue-700` (rol "role") | **Migrar a neutral** | Badge de tipo "role" no tiene razón de ser azul. Migrar a slate o usar color propio del sistema de recursos. Etapa 3. |
## Hallazgos — `PDF.blue` en exportación
@@ -51,7 +51,7 @@
| `HeatmapLegend.tsx:24` | `#10b981`, `#f59e0b`, `#ef4444` | Gradiente de la leyenda del heatmap | **Migrar a token** | Usar constantes exportadas de `colors.ts` o tokens `heatmap.*`. Etapa 3. |
| `chart-options.ts:12` | `#3b82f6` (role) | Color del tipo de recurso "role" | **Migrar a neutral** | El azul para "roles" no es marca InQ. Evaluar paleta diferenciadora de tipos en Sprint 2. |
| `chart-options.ts:12` | `#8b5cf6` (system) | Color del tipo "system" | **Mantener azul funcional** | Violeta para sistemas es una convención razonable. Evaluar en Sprint 2. |
| `chart-options.ts:123` | `#3b82f6` | Costo directo en donut chart | **Migrar a InQ orange** | El donut de composición directo/overhead debería usar naranja InQ. Etapa 3. |
| `chart-options.ts:123` | `#3b82f6` | Costo directo en donut chart | **Migrar a InQ orange** | Composición directo/overhead en donut. Decisión visual: el sector "directo" va en naranja InQ `#F59845`. El sector "overhead" NO debe ser otro naranja (pierde contraste) — usar slate-400 (`#94A3B8`) o naranja muy claro (`#FED7AA`). Decisión final en Etapa 3 mirando el render. |
| `chart-options.ts:57,69,211,224` | `#f1f5f9`, `#e2e8f0` | Grid lines y ejes de charts | **Mantener neutral** | slate-100/200 son correctos para UI de soporte. |
| `chart-options.ts:63,107,209` | `#475569` | Labels de ejes | **Mantener neutral** | slate-600, correcto. |
| `chart-options.ts:78,217,237` | `#64748b`, `#94a3b8` | Labels secundarios | **Mantener neutral** | slate-500/400, correctos. |
@@ -85,6 +85,32 @@
| Etapa 3 (Web App) | `CumulativeSavingsChart.tsx`, `TopSavingsChart.tsx`, `HeatmapLegend.tsx`, `chart-options.ts` | Migrar hex a tokens CSS/constantes |
| Etapa 4-8 (PDFs) | `pdf-sections.ts`, `pdf-export.ts`, `pdf-sections-roi.ts` | Reemplazar `PDF.blue` por array naranja InQ |
| Etapa 9 (Badge ⚡) | `BpmnCanvas.tsx` | Rediseño completo del badge a naranja sólido |
| Sprint 2 | `ResourcesPanel.tsx`, `GatewayPanel.tsx`, `chart-options.ts` | Evaluar paleta de tipos de recurso y gateways |
| Sprint 2 | `ResourcesPanel.tsx`, `chart-options.ts` | Evaluar paleta de tipos de recurso |
**No se toca nada de lo anterior en Etapa 1.**
---
## Accesibilidad — Contraste de marca
**Color auditado**: `#F59845` (InQ orange) sobre fondo blanco `#FFFFFF`
**Resultado (calculado 2026-05-16)**:
```
Contrast ratio: 2.22 :1
AA normal text (4.5): FAIL
AA large text (3.0): FAIL
```
**Veredicto**: el naranja InQ no alcanza el mínimo WCAG AA en ninguna categoría de texto.
**Opciones de remediación**:
| Opción | Descripción | Trade-off |
|---|---|---|
| A | Aceptar contraste sub-WCAG en CTAs | Riesgo accesibilidad — usuarios con baja visión |
| B | Oscurecer naranja a `#D97706` en CTAs | Rompe marca parcialmente — perceptible en UI |
| C | Usar texto oscuro `#451A03` en CTAs sobre naranja | Cambia el look del botón visiblemente |
| **D** | **Forzar `font-weight: 700` en CTAs primarios** | **Cumple AA large text con texto grande/bold** |
**Propuesta para Etapa 3**: implementar opción D — `font-weight: 700` en todos los CTAs primarios y KPI cards. NO modificar componentes en esta etapa.

View File

@@ -1,5 +1,5 @@
import { TrendingDown, Calendar, BarChart3, DollarSign, Percent } from 'lucide-react'
import { formatCurrency, formatPercent } from '@/lib/format'
import { formatCurrency, formatPayback, formatPercent } from '@/lib/format'
import type { RoiResult } from '@/domain/roi'
interface RoiKpiBarProps {
@@ -9,7 +9,7 @@ interface RoiKpiBarProps {
}
function RoiKpiCard({
label, value, sub, icon, iconColor, valueColor,
label, value, sub, icon, iconColor, valueColor, compact,
}: {
label: string
value: string
@@ -17,6 +17,7 @@ function RoiKpiCard({
icon: React.ReactNode
iconColor: string
valueColor?: string
compact?: boolean
}) {
return (
<div data-testid="roi-kpi-card" className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 flex items-start gap-4">
@@ -25,7 +26,7 @@ function RoiKpiCard({
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-1">{label}</p>
<p className={`text-2xl font-bold font-mono leading-tight ${valueColor ?? 'text-slate-900'}`}>{value}</p>
<p className={`${compact ? 'text-sm' : 'text-2xl'} font-bold font-mono leading-tight ${valueColor ?? 'text-slate-900'}`}>{value}</p>
{sub && <p className="text-xs text-slate-400 mt-0.5 leading-snug">{sub}</p>}
</div>
</div>
@@ -33,13 +34,7 @@ function RoiKpiCard({
}
export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
const paybackStr = !isFinite(roi.paybackMonths)
? 'No se recupera'
: roi.paybackMonths === 0
? 'Inmediato'
: roi.paybackMonths < 24
? `${roi.paybackMonths.toFixed(1)} meses`
: `${roi.paybackYears.toFixed(1)} años`
const paybackStr = formatPayback(roi.paybackMonths)
const roiStr = !isFinite(roi.roiAnnualPercent)
? '∞%'
@@ -88,6 +83,7 @@ export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
icon={<Calendar className="h-5 w-5 text-primary" />}
iconColor="bg-primary/10"
valueColor={roi.breaksEvenInHorizon ? 'text-emerald-700' : 'text-slate-900'}
compact
/>
<RoiKpiCard
label={`Acumulado (${horizonYears} años)`}

View File

@@ -73,7 +73,7 @@ export function TransparencySection({ activities, investment, roi }: Transparenc
<span className="font-medium">
ROI inusualmente alto detectado: {formatPercent(roi.roiAnnualPercent)}.
</span>{' '}
Un ROI superior al {formatPercent(ROI_HIGH_THRESHOLD)} anual suele indicar uno de estos casos:
Un ROI superior al {formatPercent(ROI_HIGH_THRESHOLD, 0)} anual suele indicar uno de estos casos:
</p>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>Frecuencia anual del proceso sobreestimada</li>

View File

@@ -1,5 +1,5 @@
import Papa from 'papaparse'
import { formatSimulationTimestamp } from '@/lib/format'
import { formatPayback, formatSimulationTimestamp } from '@/lib/format'
import { calculateRoi } from '@/domain/roi'
import { buildFileName } from './slug'
import type { Process, Simulation, Resource, Activity, ActivitySimResult } from '@/domain/types'
@@ -109,12 +109,6 @@ function generateRoiCsvContent(
automationInvestment: process.automationInvestment,
})
const paybackStr = !Number.isFinite(roi.paybackMonths)
? 'N/A'
: roi.paybackMonths === 0
? '0'
: formatDecimalForCsv(roi.paybackMonths, currency)
const roiStr = !Number.isFinite(roi.roiAnnualPercent)
? 'Infinito'
: formatDecimalForCsv(roi.roiAnnualPercent, currency)
@@ -128,7 +122,8 @@ function generateRoiCsvContent(
`# Costo total automatizado (${currency}): ${formatDecimalForCsv(resultAutomated.totalCost, currency)}`,
`# Ahorro por ejecución (${currency}): ${formatDecimalForCsv(roi.savingsPerExecution, currency)}`,
`# Ahorro anual (${currency}): ${formatDecimalForCsv(roi.annualSavings, currency)}`,
`# Payback (meses): ${paybackStr}`,
`# Payback (meses): ${formatPayback(roi.paybackMonths)}`,
`# Payback (meses, raw): ${Number.isFinite(roi.paybackMonths) ? roi.paybackMonths.toFixed(4) : roi.paybackMonths}`,
`# Ahorro acumulado a ${process.analysisHorizonYears} años (${currency}): ${formatDecimalForCsv(roi.cumulativeSavingsHorizon, currency)}`,
`# ROI anualizado (%): ${roiStr}`,
`# Frecuencia anual: ${process.annualFrequency}`,

View File

@@ -65,7 +65,7 @@ async function exportScenarioPdf(
title: `Análisis de costos - ${process.name}`,
author: process.clientName || '',
subject: 'Simulación de proceso BPMN',
creator: 'Process Cost Platform',
creator: 'InQ ROI',
keywords: 'bpmn, costos, simulación',
})
@@ -124,7 +124,7 @@ async function exportScenarioPdf(
docAny.addPage()
drawMethodologySection(docAny, process, PDF.margin)
addPageNumbers(docAny, 'Process Cost Platform', simulation.executedAt)
addPageNumbers(docAny, 'InQ ROI · Powered by InQuality', simulation.executedAt)
}
// ─── ROI — 4 páginas ──────────────────────────────────────────────────────────
@@ -153,7 +153,7 @@ async function exportRoiPdf(
title: `Análisis de ROI - ${process.name}`,
author: process.clientName || '',
subject: 'Análisis de retorno de inversión para automatización BPMN',
creator: 'Process Cost Platform',
creator: 'InQ ROI',
keywords: 'bpmn, roi, automatización, payback',
})
@@ -175,7 +175,7 @@ async function exportRoiPdf(
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.setTextColor(...PDF.slate500)
doc.text(' Actividades en negrita están marcadas como automatizables', PDF.margin, y + 3)
doc.text('(*) Actividades en negrita están marcadas como automatizables', PDF.margin, y + 3)
y += 8
const tableConfig = buildComparisonTableConfig(
@@ -196,7 +196,7 @@ async function exportRoiPdf(
doc.addPage()
drawRoiMethodologySection(doc, process, PDF.margin)
addPageNumbers(doc, 'Process Cost Platform · ROI', simulation.executedAt)
addPageNumbers(doc, 'InQ ROI · Powered by InQuality', simulation.executedAt)
}
function formatNum(n: number): string {

View File

@@ -1,4 +1,4 @@
import { formatCurrency, formatPercent } from '@/lib/format'
import { formatCurrency, formatPayback, formatPercent } from '@/lib/format'
import type { ActivitySimResult, Process, Activity } from '@/domain/types'
import type { RoiResult } from '@/domain/roi'
import { PDF, type DocLike } from './pdf-sections'
@@ -78,13 +78,7 @@ export function drawRoiKpiCards(
const cardW = 52, cardH = 28, gapX = 7, gapY = 5
const col1 = PDF.margin, col2 = PDF.margin + cardW + gapX, col3 = PDF.margin + (cardW + gapX) * 2
const paybackStr = !Number.isFinite(roi.paybackMonths)
? 'No se recupera'
: roi.paybackMonths === 0
? 'Inmediato'
: roi.paybackMonths < 24
? `${roi.paybackMonths.toFixed(1)} meses`
: `${roi.paybackYears.toFixed(1)} años`
const paybackStr = formatPayback(roi.paybackMonths)
const roiStr = !Number.isFinite(roi.roiAnnualPercent)
? '∞'
@@ -94,7 +88,7 @@ export function drawRoiKpiCards(
// Fila 1: 3 cards
{ label: 'AHORRO POR EJECUCIÓN', value: formatCurrency(roi.savingsPerExecution, currency), sub: `${formatPercent(roi.savingsPerExecutionPercent)} vs. actual`, positive: roi.savingsPerExecution >= 0 },
{ label: 'AHORRO ANUAL', value: formatCurrency(roi.annualSavings, currency), sub: `× ${horizonYears} años`, positive: roi.annualSavings >= 0 },
{ label: 'PAYBACK', value: paybackStr, sub: roi.breaksEvenInHorizon ? 'Dentro del horizonte' : 'Fuera del horizonte', positive: roi.breaksEvenInHorizon },
{ label: 'PAYBACK', value: paybackStr, sub: roi.breaksEvenInHorizon ? 'Dentro del horizonte' : 'Fuera del horizonte', positive: roi.breaksEvenInHorizon },
// Fila 2: 2 cards + 1 vacía
{ label: `ACUMULADO (${horizonYears} AÑOS)`, value: formatCurrency(roi.cumulativeSavingsHorizon, currency), sub: 'neto de inversión', positive: roi.cumulativeSavingsHorizon >= 0 },
{ label: 'ROI ANUAL', value: roiStr, sub: 'retorno sobre la inversión', positive: roi.roiAnnualPercent >= 0 },
@@ -127,7 +121,7 @@ export function drawRoiKpiCards(
// Valor
const valueColor = kpi.positive ? [16, 185, 129] as [number,number,number] : [239, 68, 68] as [number,number,number]
doc.setFont('helvetica', 'bold')
doc.setFontSize(kpi.value.length > 14 ? 9 : 11)
doc.setFontSize(kpi.value.length > 22 ? 7 : kpi.value.length > 14 ? 9 : 11)
doc.setTextColor(...valueColor)
doc.text(kpi.value, x + 3, y + 16)
@@ -356,7 +350,7 @@ export function drawTransparencyBlock(
if (roiHigh) {
blocks.push({
text: doc.splitTextToSize(
`ROI inusualmente alto detectado: ${formatPercent(roi.roiAnnualPercent)}. Un ROI > ${formatPercent(ROI_HIGH_THRESHOLD)} anual suele indicar frecuencia sobreestimada, costo automatizado subestimado o inversión insuficiente. Revisá antes de presentar al cliente.`,
`ROI inusualmente alto detectado: ${formatPercent(roi.roiAnnualPercent)}. Un ROI > ${formatPercent(ROI_HIGH_THRESHOLD, 0)} anual suele indicar frecuencia sobreestimada, costo automatizado subestimado o inversión insuficiente. Revisá antes de presentar al cliente.`,
boxW - 8
),
fontSize: 8,

View File

@@ -25,6 +25,32 @@ export function formatMinutes(minutes: number): string {
return m > 0 ? `${h}h ${m}min` : `${h}h`
}
export function formatPayback(months: number): string {
if (Number.isNaN(months) || !isFinite(months)) {
return 'No recupera en horizonte'
}
if (months < 0) {
return 'Sin payback (costo aumenta)'
}
if (months === 0) {
return 'Inmediato'
}
if (months < 0.1) {
return '< 1 día'
}
if (months < 1) {
return `< 1 mes (${months.toFixed(2)} meses)`
}
if (months < 12) {
return `${months.toFixed(1)} meses`
}
if (months < 24) {
const years = (months / 12).toFixed(1)
return `${months.toFixed(1)} meses (~${years} años)`
}
return `${(months / 12).toFixed(1)} años`
}
// Formato de timestamp de simulación: "13 de mayo de 2026 a las 09:45"
// Locale explícito es-PY — nunca depende del locale del browser del cliente.
export function formatSimulationTimestamp(ts: number): { date: string; time: string } {

View File

@@ -0,0 +1,174 @@
/**
* Etapa 2 Sprint 1.5 — Validación visual de formatPayback.
*
* 1. Screenshot del RoiKpiBar mostrando payback formateado (no "0.0 meses").
* 2. Screenshot de TransparencySection con ROI > 1000%.
* 3. PDF correcto del tab "Comparación + ROI" guardado para validación visual.
*
* Setup: medium-with-gateways, task_recv + task_score + task_analisis automatizables,
* $200 costo directo / $20 costo automatizado, 5000 ejecuciones/año, $80.000 inversión.
*
* Root cause del PDF erróneo en run anterior: el test exportaba antes de navegar al
* tab ROI, por lo que activeTab = 'actual' y se generaba el PDF del escenario actual.
* Fix: navegar al tab "Comparación + ROI" ANTES de exportar.
*/
import { test, expect } from '@playwright/test'
import { resolve } from 'path'
import { mkdirSync } from 'fs'
import { fileURLToPath } from 'url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const BPMN_DIR = resolve(__dirname, '../../public/sample-processes')
const OUT_DIR = resolve(__dirname, './__output__')
test.beforeAll(() => { mkdirSync(OUT_DIR, { recursive: true }) })
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function importBpmn(page: import('@playwright/test').Page, filename: string) {
await page.goto('/')
await page.locator('#bpmn-file-input').setInputFiles(resolve(BPMN_DIR, filename))
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
await page.waitForSelector('button:has-text("Simular")', { timeout: 10_000 })
await page.waitForTimeout(1_000)
}
async function setActivityCosts(
page: import('@playwright/test').Page,
elementId: string,
directCost: number,
automatedCost: number,
automatedTime: number
) {
await page.locator(`[data-element-id="${elementId}"]`).first().click()
await page.waitForTimeout(500)
const toggle = page.getByRole('switch', { name: /automatizable/i })
if (await toggle.getAttribute('aria-checked') === 'false') {
await toggle.click()
await page.waitForTimeout(300)
}
const directCostInput = page.getByLabel(/Costo directo fijo/i)
await directCostInput.fill(String(directCost))
const autoCostInput = page.getByLabel(/Costo automatizado/i)
await autoCostInput.fill(String(automatedCost))
const autoTimeInput = page.getByLabel(/Tiempo automatizado/i)
await autoTimeInput.fill(String(automatedTime))
const guardar = page.getByRole('button', { name: /Guardar/i })
await guardar.click()
await page.waitForTimeout(500)
}
async function configureGlobal(
page: import('@playwright/test').Page,
annualFrequency: number,
investment: number
) {
const globalTab = page.getByRole('tab', { name: /Global/i })
await globalTab.click()
await page.waitForTimeout(300)
const freqInput = page.getByLabel(/Frecuencia anual/i)
await freqInput.fill(String(annualFrequency))
const investInput = page.getByLabel(/Inversión en automatización/i)
await investInput.fill(String(investment))
const guardar = page.getByRole('button', { name: /Guardar/i })
await guardar.click()
await page.waitForTimeout(500)
}
async function setupRealisticScenario(page: import('@playwright/test').Page) {
await importBpmn(page, 'medium-with-gateways.bpmn')
await setActivityCosts(page, 'task_recv', 200, 20, 10)
await setActivityCosts(page, 'task_score', 200, 20, 10)
await setActivityCosts(page, 'task_analisis', 200, 20, 10)
await configureGlobal(page, 5000, 80000)
const simBtn = page.getByRole('button', { name: 'Simular' })
await expect(simBtn).not.toBeDisabled({ timeout: 5_000 })
await simBtn.click()
await page.waitForURL(/\/report\//, { timeout: 20_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 15_000 })
await page.waitForTimeout(800)
// Navegar al tab Comparación + ROI — NECESARIO para que activeTab sea 'roi'
const roiTab = page.getByRole('tab', { name: /Comparación/i })
await expect(roiTab).not.toBeDisabled({ timeout: 5_000 })
await roiTab.click()
await page.waitForSelector('[data-testid="roi-kpi-card"]', { timeout: 10_000 })
await page.waitForTimeout(800)
}
// ─── Tests ────────────────────────────────────────────────────────────────────
test('screenshot RoiKpiBar — payback formateado (no "0.0 meses")', async ({ page }) => {
await setupRealisticScenario(page)
// Screenshot del viewport — muestra KPI bar
await page.screenshot({
path: resolve(OUT_DIR, 'payback-roi-kpi-bar.png'),
fullPage: false,
})
// Verificar que el payback card NO muestra "0.0 meses"
const cards = page.locator('[data-testid="roi-kpi-card"]')
const texts = await cards.allTextContents()
console.log('KPI card texts:', JSON.stringify(texts, null, 2))
const hasOldFormat = texts.some(t => /\b0\.0 meses\b/.test(t))
expect(hasOldFormat, 'Payback card must not show "0.0 meses" — use formatPayback').toBe(false)
})
test('screenshot TransparencySection — ROI > 1000% dispara aviso', async ({ page }) => {
await setupRealisticScenario(page)
// Verificar que ROI es > 1000% (condición para disparar Transparencia)
const cards = page.locator('[data-testid="roi-kpi-card"]')
const texts = await cards.allTextContents()
const roiText = texts.find(t => t.toLowerCase().includes('roi anual')) ?? ''
console.log('ROI card text:', roiText)
// Scroll hasta el final de la página para revelar TransparencySection
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await page.waitForTimeout(500)
// Verificar que la sección existe en el DOM
const transparency = page.locator('text=Transparencia metodológica').first()
await expect(transparency).toBeVisible({ timeout: 5_000 })
// Screenshot full-page para capturar la sección
await page.screenshot({
path: resolve(OUT_DIR, 'payback-transparency-section.png'),
fullPage: true,
})
console.log('TransparencySection visible con ROI:', roiText)
})
test('PDF ROI correcto — tab "Comparación + ROI" activo al exportar', async ({ page }) => {
// Root cause del PDF erróneo anterior: exportar con activeTab = 'actual'.
// Fix: setupRealisticScenario ya navega al tab ROI ANTES de este punto.
await setupRealisticScenario(page)
// Tab ROI ya activo — el BPMN canvas no existe en este tab, no esperar por él
await page.waitForTimeout(500)
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 30_000 }),
page.getByRole('button', { name: 'Exportar PDF' }).click(),
])
const outPath = resolve(OUT_DIR, 'etapa-2-payback-roi-sample.pdf')
await download.saveAs(outPath)
console.log('PDF ROI guardado:', outPath)
expect(download.suggestedFilename()).toMatch(/_roi\.pdf$|\.pdf$/)
// El nombre debe contener "roi" si la implementación usa activeTab en el slug
console.log('Nombre sugerido:', download.suggestedFilename())
})

View File

@@ -147,9 +147,9 @@ describe('RoiKpiBar', () => {
expect(text).toMatch(/810/)
})
it('payback Infinity → muestra "No se recupera"', () => {
it('payback Infinity → muestra "No recupera en horizonte"', () => {
render(<RoiKpiBar roi={roiNoSavings} currency="USD" horizonYears={3} />)
expect(screen.getByText(/No se recupera/i)).toBeInTheDocument()
expect(screen.getByText(/No recupera en horizonte/i)).toBeInTheDocument()
})
it('payback 0 (inversión cero) → muestra "Inmediato"', () => {

View File

@@ -1,21 +1,67 @@
/**
* Tests de formatPayback — Sprint 1.5 Etapa 2.
*
* Stubs preparados. La implementación se agrega en Etapa 2.
* Los 7 casos están definidos en SPRINT_1_5_BRIEF_REDISENIO_VISUAL.md § 3.4.
*
* 7 casos base + 5 edge cases = 12 tests.
* Regla: un payback de "0.0 meses" (como mostraba el sistema con costos
* artificialmente bajos) es confuso. formatPayback resuelve esto con
* una escala legible para el consultor y el cliente.
*/
import { describe, it } from 'vitest'
import { describe, it, expect } from 'vitest'
import { formatPayback } from '../../src/lib/format'
describe('formatPayback', () => {
it.todo('0 → "Inmediato" (inversión cero con ahorro positivo)')
it.todo('0.05 → "< 1 día" (payback de horas, prácticamente instantáneo)')
it.todo('0.5 → "< 1 mes (0.50 meses)"')
it.todo('6 → "6.0 meses"')
it.todo('15 → "15.0 meses (~1.3 años)"')
it.todo('30 → "2.5 años"')
it.todo('Infinity → "No recupera en horizonte"')
// Casos base
it('0 → "Inmediato"', () => {
expect(formatPayback(0)).toBe('Inmediato')
})
it('0.05 → "< 1 día"', () => {
expect(formatPayback(0.05)).toBe('< 1 día')
})
it('0.5 → "< 1 mes (0.50 meses)"', () => {
expect(formatPayback(0.5)).toBe('< 1 mes (0.50 meses)')
})
it('6 → "6.0 meses"', () => {
expect(formatPayback(6)).toBe('6.0 meses')
})
it('15 → "15.0 meses (~1.3 años)"', () => {
expect(formatPayback(15)).toBe('15.0 meses (~1.3 años)')
})
it('30 → "2.5 años"', () => {
expect(formatPayback(30)).toBe('2.5 años')
})
it('Infinity → "No recupera en horizonte"', () => {
expect(formatPayback(Infinity)).toBe('No recupera en horizonte')
})
// Edge cases
it('-1 → "Sin payback (costo aumenta)"', () => {
// payback negativo: costo automatizado supera al actual
expect(formatPayback(-1)).toBe('Sin payback (costo aumenta)')
})
it('-100 → "Sin payback (costo aumenta)"', () => {
// negativo grande: mismo comportamiento
expect(formatPayback(-100)).toBe('Sin payback (costo aumenta)')
})
it('NaN → "No recupera en horizonte"', () => {
// NaN cae en el primer guard
expect(formatPayback(NaN)).toBe('No recupera en horizonte')
})
it('11.95 → "11.9 meses"', () => {
// verificar que el borde con 12 no salta a "1.0 años" prematuramente
expect(formatPayback(11.95)).toBe('11.9 meses')
})
it('12.0 → "12.0 meses (~1.0 años)"', () => {
// exactamente en el umbral
expect(formatPayback(12.0)).toBe('12.0 meses (~1.0 años)')
})
})