Sprint 2 Etapas 1-2: modelo recursos utilizationMinutes+units, Dexie v3, ActivityPanel wow

This commit is contained in:
2026-05-19 21:26:34 -03:00
parent 49364f4b18
commit a01380240a
23 changed files with 292265 additions and 244 deletions

295
CLAUDE.md
View File

@@ -1,34 +1,26 @@
# CLAUDE.md — Contexto del Proyecto
# CLAUDE.md — InQ ROI · Contexto de implementación
> Este archivo es leído automáticamente por Claude Code en cada sesión.
> Define el "norte" del proyecto y las reglas no negociables.
> Leído automáticamente por Claude Code al arrancar desde este directorio.
> Contiene reglas de código, arquitectura, marca y convenciones de implementación.
> Para contexto estratégico y de colaboración ver `cowork/InQ ROI - Simulador de procesos/CLAUDE.md`.
---
## ⚠️ Reglas de Marca — NO NEGOCIABLES (Sprint 1.5+)
## ⚠️ Reglas de Marca — NO NEGOCIABLES
Estas reglas aplican a TODO string que aparezca en código, PDFs, CSVs, tests y documentación.
### 1. Ortografía de "InQ" — SIEMPRE I-n-Q
### Ortografía de "InQ" — SIEMPRE I-n-Q
```
✅ InQ ← correcto
✅ InQuality ← correcto (nombre completo)
✅ InQ ROI ← nombre del producto
❌ INQ ← bug
❌ Inq ← bug
❌ inq ← bug
❌ inQ ← bug
❌ INQ ❌ Inq ❌ inq ❌ inQ todos son bugs
```
Cualquier desviación es un bug que debe corregirse antes de hacer commit.
### 2. Nombre del producto
`InQ ROI` — nombre de trabajo para Sprint 1.5. Se reemplazará en Sprint 3-4. No debatir el nombre ahora.
### 3. Color protagonista
### Paleta protagonista
```
Primario: #F59845 (naranja InQ)
@@ -36,107 +28,34 @@ Gradiente: #F59845 → #ED3E8F (naranja → magenta InQ)
```
El gradiente se usa SOLO en KPI Hero y banners principales.
**NO usar morado de CONCILIA** (`#572FA2`, `#CB1889`) en este producto.
**NO usar colores CONCILIA** (`#572FA2`, `#CB1889`) en este producto.
### 4. Footer de PDFs
### Assets de logo
Logos disponibles en `assets/`:
- `MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png` — logo horizontal color (uso principal)
- `MARCA FINAL 2023 AREA DE SEGURIDAD 1 BCO.png` — logo horizontal blanco (sobre fondos oscuros)
- `MARCA FINAL 2023 AREA DE SEGURIDAD 2 COLOR.png` — versión con área de seguridad 2
- `MARCA FINAL 2023 AREA DE SEGURIDAD 2 ISO COLOR LOGO BCO.png` — isotipo + logo blanco
Usar el logo PNG en lugar de texto "InQ" / "InQuality" en headers de PDF y en la UI cuando corresponda.
### Footer de PDFs
Tagline obligatorio: `"Powered by InQuality"` — en todos los footers de PDF, siempre.
### 5. Rol del consultor
Si aparece un nombre de consultor en algún campo, es como "consultor responsable", no como "creador" ni "founder" del producto.
---
## Identidad del Proyecto
**Nombre de trabajo**: Process Cost Platform (nombre comercial por definir)
**Una frase**: Plataforma web para que consultores de procesos cuantifiquen costos operativos y calculen ROI de automatización a partir de archivos BPMN 2.0.
**Mantra**: *Los procesos de negocio son activos estratégicos de las empresas y un diferencial competitivo.*
**Nombre de trabajo:** InQ ROI
**Una frase:** Plataforma web para que consultores de procesos cuantifiquen costos operativos y calculen ROI de automatización a partir de archivos BPMN 2.0.
**Deploy:** Cloudflare Pages — https://process-cost-platform.pages.dev
**Estado:** Sprint 2 en curso (ver `sprints/sprint-2/BRIEF.md`)
---
## Estado Actual
- **Fase**: 1 — Sprint 1 (ROI y escenarios de automatización)
- **Fase anterior completada**: 0 — MVP "Noche Cero" (v0.1.0-mvp, deployado en https://process-cost-platform.pages.dev)
- **Documento maestro**: `ARCHITECTURE.md` (raíz del repo)
- **Audiencia primaria del Sprint 1**: equipo de InQuality (consultora de procesos)
- **Caso de uso primario**: pre-venta de proyectos de automatización con cálculo de ROI
- **Deploy**: Cloudflare Pages (estático, gratis)
---
## Reglas No Negociables — Sprint 1
### ✅ Lo que SÍ entra en Sprint 1
1. **Volumetría del proceso**: frecuencia anual de ejecución + horizonte de análisis (años)
2. **Marcado de actividades automatizables** con costos automatizados (tiempo + costo fijo)
3. **Inversión en automatización** como dato del proceso (un único valor agregado)
4. **Motor de simulación dual**: corre escenarios "actual" y "automatizado" sobre el mismo BPMN
5. **Sección de ROI en el reporte**: KPIs grandes, tabla comparativa, gráficos de payback y ahorros
6. **5 KPIs de ROI**: Ahorro por ejecución, Ahorro anual, Payback (meses), Ahorro acumulado a N años, ROI % anualizado
7. **Mecanismo de transparencia**: detecta valores cero o inversión cero y los expone en el reporte
8. **Migración Dexie v1 → v2** con datos existentes preservados
9. **Export PDF/CSV ampliado** para incluir páginas/datos de ROI
10. **Documento metodológico anexo** sobre cómo calcular el costo automatizado por ejecución
### ❌ Lo que NO entra en Sprint 1
- Múltiples escenarios (más de 2)
- Escenarios como entidad separada (modelo B) — sigue siendo modelo A: dos pares de campos por actividad
- VPN, TIR, tasa de descuento (cálculos financieros sofisticados)
- Captura desglosada del costo de automatización (licencias, desarrollo, infra) — un único campo
- Análisis de sensibilidad ("¿qué pasa si la frecuencia cae 30%?")
- Distribuciones probabilísticas
- Modelar bots como recursos con capacidad
- Multi-tenant, login, colaboración, versionado, IA, modo oscuro, i18n, PWA
**Si dudás si una feature entra: NO ENTRA.** Defendé el alcance. Anotala en `TODO.md`.
---
## Decisiones Arquitectónicas Adoptadas (Sprint 1)
### Modelo de escenarios: Modelo A (dos campos por actividad)
Cada actividad tiene:
- `directCostFixed` y `executionTimeMinutes` (escenario actual, ya existían)
- `automatedCostFixed` y `automatedTimeMinutes` (nuevos, escenario automatizado)
- `automatable: boolean` (default false)
**NO se modela con dos diagramas distintos.** Un BPMN, dos pares de campos por actividad. El motor recibe un parámetro `scenario: 'actual' | 'automated'` y aplica los campos correspondientes.
Migración a "escenarios como entidad" se evaluará en Sprint 3-4 si InQuality lo demanda.
### Volumetría: multiplicador en el reporte, no en el motor
El motor sigue siendo **determinístico** y calcula "una ejecución". La volumetría (frecuencia anual + horizonte) es un parámetro del proceso que el reporte usa para extrapolar:
- `costoAnual = costoUnitario × frecuenciaAnual`
- `costoHorizonte = costoAnual × horizonteAños`
No se introduce VPN ni Monte Carlo en Sprint 1.
### ROI: cálculos nominales simples
- `savingsPerExecution = costActual - costAutomated`
- `annualSavings = savingsPerExecution × annualFrequency`
- `paybackMonths = (automationInvestment / annualSavings) × 12`
- `cumulativeSavingsHorizon = (annualSavings × horizonYears) - automationInvestment`
- `roiAnnualPercent = (annualSavings / automationInvestment) × 100`
Edge cases manejados explícitamente: inversión cero, ahorro negativo, división por cero.
### Transparencia metodológica
Si se detectan valores cero (costo automatizado en cero, inversión en cero, etc.), el reporte muestra una **sección visible pero discreta** ("Transparencia metodológica") con la lista de valores no informados. **Es feature, no bug**: convierte una posible debilidad en señal de seriedad profesional para el cliente.
---
## Stack Tecnológico (sin cambios respecto al MVP)
## Stack Tecnológico (sin cambios hasta nuevo aviso)
| Capa | Tecnología |
|---|---|
@@ -148,14 +67,14 @@ Si se detectan valores cero (costo automatizado en cero, inversión en cero, etc
| UI | shadcn/ui sobre Tailwind CSS |
| Iconos | Lucide React |
| Gráficos | Apache ECharts (vía echarts-for-react) |
| Persistencia | IndexedDB vía Dexie |
| Persistencia | IndexedDB vía Dexie (actualmente v2) |
| Validación | Zod |
| Export PDF | jsPDF + jsPDF-autotable + html2canvas |
| Export CSV | papaparse |
| Testing | Vitest + Playwright |
| Deploy | Cloudflare Pages |
**No introduzcas librerías fuera de esta lista sin justificación explícita.**
**No introduzcas librerías fuera de esta lista sin justificación explícita y consulta al director.**
---
@@ -167,34 +86,53 @@ src/
├── App.tsx
├── router.tsx
├── domain/ ← lógica de negocio pura, sin React, testeable
│ ├── types.ts
│ ├── simulation.ts ← motor de simulación (acepta scenario)
│ ├── roi.ts ← NUEVO en Sprint 1: cálculos puros de ROI
│ ├── types.ts ← tipos TypeScript del dominio
│ ├── simulation.ts ← motor de simulación (acepta scenario: 'actual' | 'automated')
│ ├── roi.ts ← cálculos puros de ROI
│ ├── bpmn-parser.ts
│ └── schemas.ts ← Zod schemas
├── store/ ← Zustand stores
├── persistence/ ← Dexie / IndexedDB (v2 en Sprint 1)
├── persistence/ ← Dexie / IndexedDB
│ └── db.ts ← schema + migraciones (versión actual: v2)
├── features/ ← organizado por feature, no por tipo
│ ├── import/
│ ├── workspace/ ← ampliado en Sprint 1 (panel de automatización)
│ ├── simulation/ ← ampliado (dispara dual scenario)
│ └── report/ ← ampliado (tab Comparación + ROI)
│ ├── workspace/ ← ActivityPanel, ResourcesPanel, BpmnCanvas, WorkspacePage
│ ├── simulation/
│ └── report/ ← ReportPage, ActivitiesTable, CostByResourceChart, etc.
├── components/ui/ ← shadcn copy-pastes
├── lib/ ← utilidades transversales (format, colors, pdf, csv)
│ └── export/ ← pdf-sections.ts, pdf-sections-roi.ts, csv.ts
└── styles/
```
**Reglas de dependencia (sin cambios)**:
- `domain/` NO importa de React, ni de `store/`, ni de `features/`. Es puro.
### Reglas de dependencia (no negociables)
- `domain/` **NO** importa de React, ni de `store/`, ni de `features/`. Es puro.
- `features/` puede importar de `domain/`, `store/`, `persistence/`, `lib/`, `components/`.
- `store/` puede importar de `domain/` y `persistence/`. Nada más.
---
## Estado del modelo de recursos (pre-Sprint 2)
El modelo de recursos está **parcialmente implementado** desde el MVP. Lo que existe:
- `Resource` en `types.ts`: `{ id, processId, name, type, costPerHour }`
- `ActivityResourceAssignment` embebido en `Activity`: `{ resourceId, utilizationPercent: number (0-1) }`
- Tabla `resources` en Dexie desde v1
- `ResourcesPanel.tsx`: CRUD de recursos por proceso
- `ActivityPanel.tsx`: asignación con slider de porcentaje
- Motor de simulación: ya calcula `cost = costPerHour × (executionTimeMinutes/60) × utilizationPercent`
- `CostByResourceChart.tsx`: gráfico en reporte
**Sprint 2 mejora este modelo.** No construye desde cero. Ver `sprints/sprint-2/BRIEF.md` para scope exacto.
---
## Principios de Código
1. **TypeScript strict** activado. Nada de `any` salvo en bordes con librerías sin tipos.
2. **Dominio puro**: motor y cálculos de ROI son funciones puras testeables sin DOM.
2. **Dominio puro**: funciones en `domain/` son puras, testeables sin DOM.
3. **Componentes tontos, hooks inteligentes**: lógica en custom hooks, JSX limpio.
4. **Sin lógica de negocio en componentes**.
5. **Convenciones de naming**:
@@ -208,35 +146,33 @@ src/
---
## Diferencial Visual (sigue siendo CRÍTICO)
## Diferencial Visual
El reporte sigue siendo el corazón. En Sprint 1 se le agregan páginas. Reglas:
El reporte es el corazón del producto. Reglas:
- Tipografía: **Inter** para UI, **JetBrains Mono** para números/datos
- Paleta heatmap: verde `#10b981` → amarillo `#f59e0b` → rojo `#ef4444`
- Paleta heatmap: verde `#10b981` → amarillo `#f59e0b` → rojo `#ef4444` (saturados, calibrados en E2E)
- Paleta ROI:
- Ahorro positivo: verde `#10b981`
- Ahorro marginal o neutro: gris `#64748b`
- Ahorro negativo o payback fuera de horizonte: rojo `#ef4444`
- Advertencias de transparencia: amarillo `#f59e0b` (no rojo: es informativo, no error)
- Cifras de dinero siempre formateadas con separadores y símbolo de moneda
- KPIs de ROI **más grandes** que los KPIs del reporte de costos (es la sección que vende)
- El export PDF debe verse igual o mejor que la pantalla
- Ahorro marginal: gris `#64748b`
- Ahorro negativo: rojo `#ef4444`
- Advertencias de transparencia: amarillo `#f59e0b`
- Cifras de dinero: siempre con separadores y símbolo de moneda
- KPIs de ROI más grandes que KPIs de costos
- Export PDF igual o mejor que pantalla
- Densidad de información media-alta (audiencia: consultor profesional)
---
## Definition of Done — Cada Feature
Antes de marcar una tarea como terminada:
- [ ] Funciona en el happy path
- [ ] Maneja el caso de datos vacíos sin crashear
- [ ] Maneja edge cases definidos (división por cero, valores negativos, valores cero)
- [ ] Funciona en happy path
- [ ] Maneja datos vacíos sin crashear
- [ ] Maneja edge cases (división por cero, valores negativos, cero)
- [ ] Persiste en IndexedDB cuando corresponde (con migración si cambió schema)
- [ ] Tiene estilo consistente con el resto de la app
- [ ] Si toca dominio: tiene al menos un test en Vitest
- [ ] Si toca el flujo end-to-end: tiene cobertura en Playwright
- [ ] Estilo consistente con el resto de la app
- [ ] Si toca dominio: al menos un test en Vitest
- [ ] Si toca flujo end-to-end: cobertura en Playwright
---
@@ -253,91 +189,30 @@ npx playwright test # tests E2E (Playwright headless)
---
## Cómo Trabajar Conmigo (el Usuario)
## Verificación final antes de entregar artefactos
- Cuando termines una tarea grande, **resumí qué cambió y qué falta**.
- Si una decisión no está clara, **proponé 2-3 opciones** con pros/contras antes de codear.
- Si encontrás que algo del documento de arquitectura está mal o falta, **avisame antes de improvisar**.
- No agregues features no pedidas. Si algo se te ocurre, anotalo en `TODO.md`.
- Cuando completes algo, **mostrame los archivos creados/modificados**, no los pegues completos en el chat salvo que te lo pida.
- **Validá programáticamente** lo que yo no puedo ver visualmente (uso Mac + VS Code + Claude Code sin browser interactivo). Para flujos críticos: Playwright + extracción de datos del PDF/CSV generado.
- Si un test revela algo raro: **NO racionalices el problema, investigá la causa raíz**. Los tests no mienten.
---
## Contexto del Fundador y Equipo
- Consultor de automatización de procesos basado en Paraguay
- Construye esto como herramienta consultiva propia + del equipo de InQuality + para vender a colegas LATAM
- Usa Claude Code Pro como único "equipo de desarrollo"
- Sprint 1 es para uso interno de InQuality (no para mostrar a cliente todavía)
- Casos de uso confirmados:
- Análisis de costos operativos en consultoría de procesos
- **Pre-venta de proyectos de automatización con cálculo de ROI** (caso estrella del Sprint 1)
- Valora: claridad, robustez, pulido visual, mantenibilidad, validación rigurosa
---
## Reglas de marca NO NEGOCIABLES
- "InQ" siempre se escribe I-n-Q (mayúscula-minúscula-mayúscula)
- Color protagonista: naranja #F59845
- Gradiente identidad: #F59845#ED3E8F
- NO usar morado CONCILIA (#572FA2, #CB1889)
- Footer obligatorio: "Powered by InQuality"
- No promover a Marcos como creador
## Verificación final antes de present_files
Antes de presentar cualquier PDF al usuario, verificá que:
Antes de presentar cualquier PDF o pantalla:
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)
3. No hay caracteres rotos
4. La marca "InQ" se escribe siempre I-n-Q
---
## Política de iniciativa proactiva
Cuando detectés una oportunidad de mejora fuera del scope del prompt actual:
Antes de aplicar un cambio fuera del scope del prompt actual, evaluar en orden:
- Cambios triviales (variables internas, reordenar imports): ejecutar
- Cambios visibles pero reversibles (wording, spacing, tooltips):
ejecutar y reportar explícitamente al final
- Cambios de marca, arquitectura o datos (paleta, dependencias,
estructura, features nuevas): CONSULTAR ANTES DE PROCEDER
1. ¿Resuelve un ítem listado en `TECH_DEBT.md`, `BACKLOG.md` o archivos de planificación? → **Nivel 3** (consultar antes)
2. ¿Choca con restricción explícita del prompt ("NO entra", "NO tocar")? → **Nivel 3**
3. ¿Modifica paginación, layout, estructura de salida, o contenido visible? → **Nivel 2** mínimo (ejecutar y reportar)
4. ¿Afecta marca, identidad, dependencias o configuración? → **Nivel 3**
5. Si nada aplica → **Nivel 1** (ejecutar libremente)
Estructura de propuesta cuando hay un cambio Nivel 3:
PROPUESTA DE CAMBIO NIVEL 3 — requiere aprobación antes de implementar
Cambio sugerido: [descripción]
Justificación: [por qué lo considero valioso]
Impacto: [qué afecta]
Alternativa si se rechaza: [qué hacer en lugar]
Ver methodology/PRINCIPLES.md sección "Política de iniciativa agéntica" para detalle completo.
### Criterios refinados (post-Sprint 1.5)
Antes de aplicar un cambio fuera del scope explícito del prompt actual,
evaluá en este orden:
1. ¿El cambio resuelve un ítem listado en TECH_DEBT.md, BACKLOG.md u
archivos de planificación? → Nivel 3 (consultar), sin importar el
tamaño técnico
2. ¿Choca con restricción explícita del prompt ("NO entra", "NO tocar")?
→ Nivel 3 (consultar)
3. ¿Modifica paginación, layout, estructura de salida, o contenido
visible? → Nivel 2 mínimo (reportar)
4. ¿Afecta marca, identidad, dependencias o configuración? → Nivel 3
5. Si nada aplica → Nivel 1
Argumentos NO válidos para evitar consulta:
Argumentos **NO válidos** para evitar consulta:
- "Es mejora de legibilidad"
- "Es consistencia con etapa anterior" (verificar si esa etapa fue
autorizada)
- "Es trivial de revertir" (reversibilidad NO es criterio de clasificación)
- "Resuelve un bug menor" (si el bug está en TECH_DEBT.md, es Nivel 3)
- "Es consistencia con etapa anterior"
- "Es trivial de revertir"
- "Resuelve un bug menor" (si está en TECH_DEBT.md, es Nivel 3)
Referencia completa: methodology/PATTERNS.md C.6 refinado +
methodology/ANTI_PATTERNS.md AP.8.
Ver `methodology/PATTERNS.md` C.6 y `methodology/ANTI_PATTERNS.md` AP.8.

View File

@@ -18,7 +18,64 @@
## Hallazgos activos
*(Sin hallazgos al abrir Sprint 2 — archivo limpio)*
### [CURRENT-SPRINT] ActivityPanel en estado intermedio roto — Etapa 1
**Origen:** Etapa 1. Detectado en validación visual del director.
**Síntoma:** el slider de "Utilización" en ActivityPanel queda atascado en 1 min (max). Recursos nuevos quedan en 0 min inamovibles. La UI de asignación de recursos es inutilizable en el estado actual.
**Causa raíz:** Claude Code modificó `ActivityPanel.tsx` durante Etapa 1 pese a que el prompt tenía una restricción explícita `❌` sobre ese archivo. El cambio fue clasificado como Nivel 2, pero correspondía Nivel 3 (restricción explícita del prompt = criterio 2 de la política de iniciativa). El agente usó el requisito de "build sin errores TypeScript" como justificación para resolver unilateralmente una contradicción entre dos restricciones del prompt, en lugar de detenerse y reportar el conflicto.
**Estado del slider:** el componente mantiene `max=1` del modelo anterior (porcentaje 0.01.0). Ahora lee `utilizationMinutes` (entero), por lo que solo acepta 0 o 1 minuto. Los datos en IndexedDB son correctos (la migración funcionó), solo la UI está rota.
**Resolución:** Etapa 2 reescribe ActivityPanel completamente. Este ítem se cierra al completar Etapa 2.
---
### [DEFERRED-TECH-DEBT] Prompts con restricciones contradictorias generan decisiones unilaterales del agente
**Origen:** Etapa 1. Hallazgo de método.
**Problema:** el ETAPA_1_PROMPT.md tenía dos requisitos que colisionaban:
- "❌ No tocar `ActivityPanel.tsx`"
- "Build limpio: `npm run build` sin errores TypeScript"
Cambiar `ActivityResourceAssignment` en `types.ts` inevitablemente produce errores TypeScript en `ActivityPanel.tsx`. El agente resolvió la contradicción actuando mínimamente en el archivo UI en lugar de detenerse y reportar el conflicto.
**Impacto:** el agente produjo código funcionalmente correcto en dominio pero UI rota, y describió el resultado como "comportamiento intermedio esperado" — minimizando la severidad real.
**Acción preventiva para todos los prompts futuros:**
1. Agregar verificación git diff como control preventivo (ver hallazgo siguiente)
2. Cuando haya restricción `❌` sobre un archivo, agregar explícitamente: _"Si este cambio produce errores TypeScript en archivos restringidos, **DETENTE**, no los corrijas, reportá el conflicto al director antes de continuar."_
**Destino al cierre del sprint:** promover a `methodology/ANTI_PATTERNS.md` como nuevo caso documentado.
---
### [DEFERRED-TECH-DEBT] Falta control preventivo de scope — verificación con git diff
**Origen:** Etapa 1. Hallazgo de método.
**Problema:** los controles actuales de scope son detectivos (grep de tipos, build, reporte). Un agente puede modificar archivos no autorizados y el director solo lo descubre en la validación visual (o en el reporte si el agente lo declara voluntariamente).
**Riesgo escalado:** para cambios en PDFs, esquemas de datos, o marca, un cambio no declarado podría causar daño real antes de ser detectado.
**Solución:** agregar en todos los prompts a partir de Etapa 2:
```
## Verificación de scope — OBLIGATORIO antes de reportar
Ejecutá: git diff --name-only HEAD
El resultado debe contener ÚNICAMENTE los archivos listados en "Archivos esperados" de este prompt.
Si aparece un archivo no esperado:
1. Revertilo: git checkout HEAD -- <archivo>
2. Documentá el conflicto en el reporte bajo "Conflictos de scope"
3. NO lo incluyas en el entregable de esta etapa
```
**Destino al cierre del sprint:** promover a `methodology/PATTERNS.md` como control C.X obligatorio en todo prompt de Claude Code.
---

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -0,0 +1,115 @@
# CLAUDE.md — InQ ROI · Contexto estratégico del asesor
> Este archivo es leído por el asesor técnico-estratégico en sesiones de Cowork.
> Contiene estado del proyecto, decisiones activas y referencias operativas.
> Las reglas de implementación (stack, código, marca en código) están en `simulador-web/CLAUDE.md`.
> El rol del asesor y el método de trabajo están en las instrucciones del proyecto (system prompt).
---
## Estado actual del proyecto
**Sprint:** 2 (en curso)
**Fase:** 1 — ROI y escenarios de automatización
**Deploy activo:** https://process-cost-platform.pages.dev
**Usuario objetivo actual:** equipo de InQuality — uso interno en talleres y pre-venta
**Sprint 1.5 cerrado el 2026-05-18.** Entregó: identidad visual completa, PDFs de clase profesional (3 documentos), badge ⚡ en canvas BPMN, ROI con 5 KPIs, sistema de componentes establecido.
---
## Eje de Sprint 2
**Feature central:** completar y mejorar el modelo de recursos con costo por hora (ya parcialmente implementado desde el MVP). Ver `simulador-web/sprints/sprint-2/BRIEF.md` para scope completo.
**Hallazgo relevante:** el modelo de recursos existe en el código (`Resource`, `ActivityResourceAssignment`, `ResourcesPanel`, lógica de simulación). Sprint 2 migra el modelo de `utilizationPercent``utilizationMinutes + units` y construye la experiencia "wow" encima.
**Etapas definidas:**
| Etapa | Contenido |
|---|---|
| 1 | Migración modelo: `utilizationPercent``utilizationMinutes + units`. Dexie v3. Tests. |
| 2 | ActivityPanel "wow": inputs de minutos/unidades, cálculo en tiempo real, helper contextual |
| 3 | Reporte web: desglose de recursos por actividad, CumulativeSavingsChart -inversión, fixes TECH_DEBT |
| 4 | UX rápidos: edición inline proceso/cliente |
| 5 | PDF: tabla de recursos + verificación truncamiento |
| 6 | E2E tests + validación visual del director |
---
## Decisiones estratégicas activas (no reabrir)
### Identidad visual — estable hasta Sprint 4+
Paleta naranja `#F59845`, gradiente `→ #ED3E8F`, system-ui stack, componentes establecidos. No reabrir.
### Rebalanceo web/PDF — Sprint 2+
60-70% web, 15-25% PDF, 10-15% infraestructura, 5-10% docs. Origen: cierre Sprint 1.5.
### Criterio de wow — obligatorio
Cada etapa con feature nueva incluye versión mínima + versión wow. Default: wow si costo extra < 40%.
### Modelo de recursos — decisiones de Sprint 2
- **Scope:** por proceso (catálogo global en Sprint 3 con feedback real)
- **Costo fijo:** convive con recursos. Costo total = fijo + recursos.
- **Escenario automatizado:** recursos solo en AS-IS. TO-BE sigue con `automatedCostFixed`.
- **Modelo:** `utilizationMinutes + units` reemplaza `utilizationPercent`.
### Modelo de escenarios — diferido
Modelo B (dos BPMNs AS-IS / TO-BE) diferido a Sprint 3. Sprint 2 mantiene Modelo A.
### Múltiples simulaciones — diferido
Historial de simulaciones y comparación de escenarios diferido a Sprint 3.
---
## Decisiones de producto pendientes de input externo
- **Posicionamiento:** ¿herramienta interna InQuality o venta a otras consultoras? Post-Sprint 2.
- **Nombre comercial:** "InQ ROI" es nombre de trabajo. Revisar Sprint 3-4.
- **Métricas de éxito:** a definir con datos reales de uso interno.
- **Manual de marca formal:** cuando exista, incorporar. Assets disponibles en `assets/`.
---
## Definiciones estables — no reabrir hasta nuevo aviso
- Stack tecnológico (React + Vite + TypeScript + stack definido en `simulador-web/CLAUDE.md`)
- Modelo de escenarios: Modelo A (un BPMN, dos pares de campos por actividad)
- ROI: cálculos nominales simples sin VPN ni Monte Carlo
- Heatmap: colores saturados `#10B981 / #F59E0B / #EF4444` (revisar Sprint 3-4)
---
## Referencias de archivos clave
| Archivo | Propósito |
|---|---|
| `simulador-web/CLAUDE.md` | Reglas de implementación para Claude Code |
| `simulador-web/sprints/sprint-2/BRIEF.md` | Scope completo de Sprint 2 |
| `simulador-web/BACKLOG.md` | Features pendientes de asignación |
| `simulador-web/TECH_DEBT.md` | Deuda técnica activa |
| `simulador-web/STRATEGIC_DIRECTION.md` | Decisiones estratégicas históricas |
| `simulador-web/OBSERVATIONS.md` | Hallazgos del sprint en curso |
| `simulador-web/methodology/` | Patrones y anti-patrones del método |
| `simulador-web/assets/` | Logos PNG + manual de marca PDF |
| `simulador-web/cowork/InQ ROI - Simulador de procesos/ARCHITECTURE.md` | Arquitectura del sistema |
---
## Herramientas externas configuradas
- **ClickUp:** workspace InQuality — backlog operativo, tareas del sprint, observaciones
- **Google Drive:** workspace InQuality — assets de marca, documentos compartidos
- Consultar estas fuentes antes de asumir que la información está en archivos locales
---
## Reglas del método (resumen operativo)
Detalle completo en `simulador-web/methodology/`. Resumen:
- **Validación visual mandatoria:** tests verdes ≠ OK formal. Todo artefacto visual requiere validación humana del director.
- **Brief como contrato:** ninguna etapa arranca sin `ETAPA_X_PROMPT.md` aprobado.
- **Iniciativa agéntica clasificada:** Nivel 1 (trivial) → ejecutar. Nivel 2 (visible) → ejecutar y reportar. Nivel 3 (marca/arquitectura/datos) → consultar antes.
- **Auditoría adversarial:** buscar lo que está mal, no confirmar lo que está bien.
- **Anti-patrón AP.8:** si un reporte parece "más limpio de lo esperado", sospechar adelanto silencioso.

311
sprints/sprint-2/BRIEF.md Normal file
View File

@@ -0,0 +1,311 @@
# Sprint 2 — Brief
> **Propósito:** completar y elevar el modelo de recursos con costo por hora (ya parcialmente implementado), con foco en la experiencia "wow" en el workspace y en la visibilidad de recursos en el reporte y el PDF. Complementado con mejoras de UX para uso en talleres y pre-venta.
>
> **Usuario objetivo:** equipo de InQuality. Uso interno en talleres de análisis de procesos y pre-venta de automatización. Ocasionalmente presentado a clientes directamente.
>
> **Sprint anterior:** Sprint 1.5 — identidad visual completa, PDFs de clase profesional, badge ⚡ en canvas, ROI con 5 KPIs. Ver cierre en `methodology/CASE_STUDIES/`.
---
## 0. Reglas no negociables (heredadas)
- **Marca:** "InQ" siempre I-n-Q. "InQ ROI" nombre del producto.
- **Paleta:** naranja `#F59845`, gradiente `#F59845 → #ED3E8F`. No usar morado CONCILIA.
- **Logo:** usar PNGs de `assets/` en lugar de texto "InQ" / "InQuality" donde corresponda. Logo principal: `MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png`.
- **Footer PDFs:** "Powered by InQuality" — obligatorio en los tres PDFs.
- **Identidad visual:** estable hasta Sprint 4+. No reabrir paleta, tipografía ni sistema de componentes.
- **Validación visual:** tests verdes ≠ aprobación final. Todo artefacto visual requiere OK del director antes de cerrar etapa.
- **Iniciativa agéntica:** clasificar cambios por nivel (1/2/3). Ver `methodology/PATTERNS.md` C.6.
Para referencia completa del manual de marca, tokens CSS y logo, ver `assets/InQ manual de marca- 3.0.pdf` y `sprints/sprint-1-5/BRIEF.md` sección 1.
---
## 1. Contexto estratégico
### 1.1 Rebalanceo web/PDF
| Componente | Inversión esperada |
|---|---|
| Web app (workspace + reporte + features) | 6070 % |
| PDF (refinamientos, nuevas secciones) | 1525 % |
| Infraestructura / deuda técnica | 1015 % |
| Documentación / metodología | 510 % |
### 1.2 Criterio de wow (obligatorio)
Cada etapa con feature nueva incluye:
- **Versión mínima:** cumple la funcionalidad.
- **Versión con wow:** mismo objetivo + 1-2 detalles de experiencia que generan impacto positivo.
Por defecto: versión con wow si el costo extra < 40% del tiempo de la mínima.
---
## 2. Estado real del modelo de recursos (pre-Sprint 2)
**El modelo de recursos existe parcialmente desde el MVP.** Sprint 2 no construye desde cero.
### Lo que ya existe en el código
| Componente | Estado |
|---|---|
| `Resource` en `types.ts` | ✅ `{ id, processId, name, type, costPerHour }` |
| `ActivityResourceAssignment` en `Activity` | ✅ `{ resourceId, utilizationPercent: number (01) }` |
| Tabla `resources` en Dexie | ✅ Existe desde v1 |
| `ResourcesPanel.tsx` | ✅ CRUD completo de recursos por proceso |
| `ActivityPanel.tsx` asignación | ✅ Slider de % de utilización |
| Motor de simulación | ✅ Calcula `cost = costPerHour × (execTimeMin/60) × utilizationPercent` |
| `CostByResourceChart.tsx` en reporte | ✅ Muestra totales por recurso (o "Sin recursos") |
| Desglose por actividad en reporte | ❌ No existe |
| PDF con recursos | ❌ No existe |
### Problema del modelo actual
El modelo `utilizationPercent` no es intuitivo para consultores: "¿qué % de la actividad ocupa este recurso?" es menos claro que "¿cuántos minutos dedica este recurso a cada ejecución?". En un taller con cliente, la pregunta inevitable es "¿cómo llegaste a ese costo?" — la respuesta con minutos es verificable, con porcentajes no.
---
## 3. Decisiones arquitectónicas del sprint (validadas por el director)
| Decisión | Resolución |
|---|---|
| Scope de recursos | Por proceso. Catálogo global en Sprint 3 con feedback real. |
| Costo fijo convive con recursos | Sí. `costoTotal = directCostFixed + costoRecursos` |
| Recursos en escenario automatizado | Solo AS-IS en Sprint 2. TO-BE sigue con `automatedCostFixed`. |
| Modelo de utilización | Migrar `utilizationPercent``utilizationMinutes + units` |
| Versión Dexie | Incrementar a v3. Migración preserva datos existentes. |
### 3.1 Modelo migrado — `ActivityResourceAssignment`
```typescript
// Antes (v2)
interface ActivityResourceAssignment {
resourceId: UUID
utilizationPercent: number // 01, fracción del tiempo de la actividad
}
// Después (v3) — Sprint 2
interface ActivityResourceAssignment {
resourceId: UUID
utilizationMinutes: number // minutos del recurso por ejecución (independiente del tiempo de la actividad)
units: number // cantidad de unidades del recurso (default 1)
}
```
### 3.2 Fórmula de costo actualizada
```
costoRecurso = resource.costPerHour × assignment.utilizationMinutes / 60 × assignment.units
costoTotalActividad = directCostFixed + SUM(costoRecurso por cada assignment)
```
### 3.3 Migración v2 → v3
La migración convierte datos existentes con la heurística:
```
utilizationMinutes = Math.round(activity.executionTimeMinutes × assignment.utilizationPercent)
units = 1
```
Para asignaciones sin ejecutionTimeMinutes disponible, `utilizationMinutes = 0` (conservador, evita sobrestimación).
### 3.4 Lo que NO cambia en Sprint 2
- `Resource` type (solo `utilizationMinutes` cambia en `ActivityResourceAssignment`, no en `Resource`)
- Motor de simulación: recibe costo ya calculado o adapta la fórmula localmente
- Escenario automatizado: sin cambios
- ROI: opera sobre resultado de simulación, sin cambio
---
## 4. Features del sprint
### Feature A — Migración del modelo de recursos (Etapa 1)
**Peso estimado:** 15% del sprint.
- Actualizar `ActivityResourceAssignment` en `types.ts` y `schemas.ts`
- Actualizar fórmula en `simulation.ts`
- Dexie v3 con función de upgrade que migra `utilizationPercent → utilizationMinutes + units`
- Test de migración: procesos con asignaciones previas se convierten correctamente
- Tests unitarios de dominio: edge cases del nuevo modelo
**Criterio de aceptación:**
- [ ] Proceso sin recursos: comportamiento idéntico al actual
- [ ] Proceso con recursos: costo calculado correctamente con nueva fórmula
- [ ] Migración v2→v3: datos preservados, sin pérdida
- [ ] Tests de dominio verdes
---
### Feature B — ActivityPanel "wow" (Etapa 2)
**Peso estimado:** 20% del sprint.
**Versión mínima:**
- Reemplazar slider de % por inputs de `utilizationMinutes` (número) y `units` (número entero ≥ 1)
- Mostrar costo calculado por recurso debajo del input
**Versión con wow (recomendada):**
Todo lo anterior más:
- Cálculo en tiempo real: el costo se actualiza al tipear (debounce de 300ms)
- Subtotal de recursos visible en el panel antes de guardar
- Helper contextual bajo el total: _"Esta actividad consume el equivalente a **2.3 días** de trabajo del Analista Senior al mes"_ (calculado con `annualFrequency / 12 × utilizationMinutes / 60 / 8`)
- El campo `directCostFixed` con label explícito: "Costo fijo (insumos, licencias, etc.)" para diferenciar visualmente de los recursos
**Criterio de aceptación:**
- [ ] Inputs reemplazan slider de %
- [ ] Costo por recurso visible antes de guardar
- [ ] Helper contextual aparece cuando hay `annualFrequency > 0` y recursos asignados
- [ ] Validación visual del director aprobada
---
### Feature C — Reporte web: desglose de recursos (Etapa 3)
**Peso estimado:** 20% del sprint.
#### C.1 Desglose por actividad en ActivitiesTable
**Versión mínima:** columna "Recursos" en la tabla que muestra el costo total de recursos de la actividad.
**Versión con wow:** filas expandibles que muestran el desglose: fijo + recurso 1 + recurso 2 + subtotal. El expand/collapse usa animación consistente con el sistema de diseño.
#### C.2 CumulativeSavingsChart — arrancar en `-inversión` (TECH_DEBT)
El gráfico web parte de `(Año 0, 0)`. El PDF ya lo hace correcto (`(Año 0, -inversión)`). Inconsistencia que notarán analistas en pre-venta. Cambiar punto inicial en `CumulativeSavingsChart.tsx`.
#### C.3 Payback label superpuesto (TECH_DEBT — trivial)
Ajustar offset del label "Payback · X meses" para que no colisione con eje Y. ~15 min.
#### C.4 Badge ⚡ — evaluar tamaño (TECH_DEBT)
Evaluar 16×16px o 18×18px. Si no genera colisiones en nodos chicos: implementar y reportar. Si genera problemas: mantener 14×14px y documentar.
**Criterio de aceptación:**
- [ ] Tabla muestra desglose de recursos por actividad
- [ ] CumulativeSavingsChart arranca en `-automationInvestment`
- [ ] Payback label no colisiona con eje Y
- [ ] Validación visual del director aprobada
---
### Feature D — UX rápidos (Etapa 4)
**Peso estimado:** 10% del sprint.
#### D.1 Edición inline de nombre de proceso y cliente
**Problema:** requiere ir a configuración global. En un taller interrumpe el flujo.
**Versión mínima:** campo editable inline en el header del workspace (click para editar, Enter/blur para guardar).
**Versión con wow:** animación sutil texto → input → texto, guardado con checkmark naranja breve que desaparece en 1.5s.
**Criterio de aceptación:**
- [ ] Se puede editar nombre y cliente sin ir a configuración
- [ ] Los cambios persisten en IndexedDB
- [ ] Validación visual del director aprobada
---
### Feature E — PDF: recursos + verificación de truncamiento (Etapa 5)
**Peso estimado:** 15% del sprint.
#### E.1 Tabla de recursos en PDF (procesos Actual/Automatizado)
Sección condicional al final del PDF (solo si hay actividades con recursos asignados):
- Tabla: Actividad | Recurso | Tipo | Costo/hora | Minutos | Unidades | Costo
- Subtotal por actividad
- Si ninguna actividad tiene recursos: la sección no aparece
**Versión mínima:** tabla simple.
**Versión con wow:** chip de color por tipo de recurso (rol = naranja, sistema = azul slate, persona = verde, equipamiento = gris, insumo = amarillo).
#### E.2 Verificación de truncamiento (TECH_DEBT)
Test E2E que valide ancho de cada línea en los tres PDFs. Si pasa → cerrar ítem. Si hay truncamiento → aplicar `doc.splitTextToSize()`.
**Criterio de aceptación:**
- [ ] Sección de recursos aparece cuando hay datos, no aparece cuando no hay
- [ ] Test E2E confirma ausencia de truncamiento en los tres PDFs
- [ ] Validación visual del director aprobada
---
## 5. Estructura de etapas
| Etapa | Contenido | Peso |
|---|---|---|
| 1 | Migración modelo: `utilizationPercent``utilizationMinutes + units`. Dexie v3. Tests unitarios. | 15% |
| 2 | ActivityPanel "wow": inputs minutos/unidades, cálculo tiempo real, helper contextual | 20% |
| 3 | Reporte web: desglose recursos por actividad (expandible), CumulativeSavingsChart -inversión, payback fix, badge | 20% |
| 4 | UX rápidos: edición inline proceso/cliente | 10% |
| 5 | PDF: tabla recursos + verificación truncamiento | 15% |
| 6 | E2E tests completos + validación visual director | 10% |
| *(buffer)* | *(imprevistos o profundizar wow)* | 10% |
**Regla operativa:** ninguna etapa arranca sin su `ETAPA_X_PROMPT.md` aprobado por el director.
---
## 6. Lo que explícitamente NO entra en Sprint 2
- Catálogo global de recursos compartido entre procesos → Sprint 3
- Recursos en escenario automatizado (TO-BE) → Sprint 3 con Modelo B
- Múltiples simulaciones / historial de escenarios → Sprint 3
- Distribuciones de probabilidad → Fase 2
- Export Excel → Backlog sin driver urgente
- Modo oscuro, i18n, PWA → Fase 2
- Login, multi-tenant, colaboración → Fase 2
---
## 7. Deuda técnica que se cierra en este sprint
| Ítem en TECH_DEBT.md | Etapa |
|---|---|
| CumulativeSavingsChart arranca en `-inversión` (web) | 3 |
| Payback label superpuesto con eje Y | 3 |
| Badge ⚡ tamaño (evaluar 16-18px) | 3 |
| Verificación truncamiento notas metodológicas PDF | 5 |
---
## 8. Definition of Done — Sprint 2
- [ ] Features AE: todos los criterios de aceptación cumplen
- [ ] Migración Dexie v2→v3: test automatizado confirma preservación de datos
- [ ] Tests unitarios: fórmula nueva, edge cases (sin recursos, units > 1, solo fijo, mixto)
- [ ] E2E: flujo completo asignar recurso → simular → verificar costo en reporte → exportar PDF
- [ ] Validación visual: reporte web con desglose, PDF con tabla de recursos, chart con -inversión
- [ ] TECH_DEBT.md actualizado: ítems cerrados documentados
- [ ] OBSERVATIONS.md limpio al cerrar
- [ ] STRATEGIC_DIRECTION.md actualizado con reflexiones post-sprint
- [ ] Ratio web/PDF respetado (verificar con conteo de etapas)
---
## 9. Riesgos
| Riesgo | Probabilidad | Mitigación |
|---|---|---|
| La migración de `utilizationPercent``utilizationMinutes` produce valores incorrectos en procesos existentes | Media | Test de migración obligatorio antes de cualquier otra etapa. La heurística es conservadora (redondeo hacia abajo). |
| El helper contextual en ActivityPanel requiere acceso a `annualFrequency` del proceso | Baja | `useReportData` o contexto del workspace ya tiene el proceso. Verificar en Etapa 2. |
| La tabla de recursos en PDF excede el espacio disponible en página 4 | Media | Diseñar como página separada si supera el espacio. Sección completamente condicional. |
---
## 10. Referencias
- `CLAUDE.md` (simulador-web) — reglas de implementación
- `STRATEGIC_DIRECTION.md` — decisiones estratégicas post-Sprint 1.5
- `BACKLOG.md` — ítems "Recursos", "Edición inline"
- `TECH_DEBT.md` — ítems CumulativeSavingsChart, Payback, Badge, Truncamiento
- `methodology/PATTERNS.md` — C.6, C.1
- `methodology/ANTI_PATTERNS.md` — AP.8
- `sprints/sprint-1-5/BRIEF.md` — manual de marca completo y tokens CSS
- `assets/InQ manual de marca- 3.0.pdf` — manual de marca oficial

View File

@@ -0,0 +1,259 @@
# Sprint 2 — Etapa 1: Migración del modelo de recursos
> **Tipo:** dominio puro + persistencia. Sin UI. Sin cambios visuales.
> **Objetivo:** migrar `ActivityResourceAssignment.utilizationPercent` → `utilizationMinutes + units`, actualizar la fórmula de simulación, incrementar Dexie a v3, y garantizar que los datos existentes se preservan correctamente.
> **Resultado esperado:** tests verdes + reporte de archivos modificados. La UI en Etapas 23.
---
## Contexto imprescindible
### Estado actual del modelo (lo que vas a encontrar en el código)
```typescript
// src/domain/types.ts — HOY
interface ActivityResourceAssignment {
resourceId: UUID
utilizationPercent: number // 01, fracción del tiempo de la actividad
}
// Fórmula actual en src/domain/simulation.ts
const hoursUsed = (executionTimeMinutes / 60) * assignment.utilizationPercent
const cost = resource.costPerHour * hoursUsed * execProb
```
```typescript
// src/domain/schemas.ts — HOY
export const ActivityResourceAssignmentSchema = z.object({
resourceId: z.string().uuid(),
utilizationPercent: z.number().min(0).max(1),
})
```
```typescript
// src/persistence/db.ts — HOY
// Dexie versión 2 (v1 original + v2 de Sprint 1)
// La tabla 'resources' ya existe desde v1.
// ActivityResourceAssignment está embebido en Activity (no es tabla propia).
```
### Lo que NO existe todavía (no confundir con lo anterior)
- No existe tabla `resourceAssignments` separada. Los assignments están embebidos en el array `Activity.assignedResources`.
- No existe campo `utilizationMinutes`. Solo existe `utilizationPercent`.
- No existe campo `units`. Hay que agregarlo.
---
## Cambios requeridos
### 1. `src/domain/types.ts`
Reemplazar `ActivityResourceAssignment`:
```typescript
// ANTES
export interface ActivityResourceAssignment {
resourceId: UUID
utilizationPercent: number
}
// DESPUÉS
export interface ActivityResourceAssignment {
resourceId: UUID
utilizationMinutes: number // minutos que este recurso dedica por ejecución (independiente del tiempo de la actividad)
units: number // cantidad de unidades del recurso (ej: 2 impresoras). Default: 1.
}
```
El tipo `Resource` NO cambia. Solo cambia `ActivityResourceAssignment`.
### 2. `src/domain/schemas.ts`
Reemplazar `ActivityResourceAssignmentSchema`:
```typescript
// ANTES
export const ActivityResourceAssignmentSchema = z.object({
resourceId: z.string().uuid(),
utilizationPercent: z.number().min(0).max(1),
})
// DESPUÉS
export const ActivityResourceAssignmentSchema = z.object({
resourceId: z.string().uuid(),
utilizationMinutes: z.number().min(0, 'Los minutos no pueden ser negativos'),
units: z.number().int().min(1, 'Las unidades deben ser al menos 1').default(1),
})
```
### 3. `src/domain/simulation.ts`
Actualizar la fórmula de costo de recursos. Buscar el bloque que calcula `hoursUsed` y reemplazarlo:
```typescript
// ANTES
const hoursUsed = (executionTimeMinutes / 60) * assignment.utilizationPercent
const cost = resource.costPerHour * hoursUsed * execProb
// DESPUÉS
const hoursUsed = (assignment.utilizationMinutes / 60) * assignment.units
const cost = resource.costPerHour * hoursUsed * execProb
```
El `execProb` se mantiene igual — sigue siendo la probabilidad de ejecución del nodo en el grafo BPMN. El costo esperado del recurso por ejecución del proceso es `costPerHour × utilizationMinutes/60 × units × execProb`.
También actualizar la línea de acumulación de minutos por recurso (buscar `resourceTotals`):
```typescript
// ANTES
minutes: prev.minutes + activity.executionTimeMinutes * assignment.utilizationPercent * execProb,
// DESPUÉS
minutes: prev.minutes + assignment.utilizationMinutes * assignment.units * execProb,
```
### 4. `src/persistence/db.ts`
Agregar versión 3 con función de upgrade. La migración convierte los assignments embebidos en Activity:
```typescript
this.version(3)
.stores({
// Los stores no cambian — ActivityResourceAssignment está embebido en Activity, no es tabla propia
processes: 'id, name, updatedAt',
activities: 'id, processId, bpmnElementId',
gateways: 'id, processId, bpmnElementId',
resources: 'id, processId, name',
simulations: 'id, processId, executedAt',
})
.upgrade(async (tx) => {
// Migrar assignedResources de cada actividad: utilizationPercent → utilizationMinutes + units
await tx.table('activities').toCollection().modify((act) => {
if (!Array.isArray(act.assignedResources)) {
act.assignedResources = []
return
}
act.assignedResources = act.assignedResources.map((assignment: Record<string, unknown>) => {
if ('utilizationPercent' in assignment) {
// Heurística de conversión: minutos = round(executionTimeMinutes × utilizationPercent)
// Si executionTimeMinutes no está disponible o es 0, usar 0 (conservador)
const execMinutes = typeof act.executionTimeMinutes === 'number' ? act.executionTimeMinutes : 0
const utilizationMinutes = Math.round(execMinutes * (assignment.utilizationPercent as number))
return {
resourceId: assignment.resourceId,
utilizationMinutes,
units: 1,
}
}
// Ya está en el nuevo formato (por si se corre la migración dos veces)
if ('utilizationMinutes' in assignment) {
return {
resourceId: assignment.resourceId,
utilizationMinutes: assignment.utilizationMinutes ?? 0,
units: assignment.units ?? 1,
}
}
// Fallback defensivo
return { resourceId: assignment.resourceId, utilizationMinutes: 0, units: 1 }
})
})
})
```
**Importante:** no eliminar las versiones 1 y 2 anteriores. Dexie requiere el historial completo de versiones.
### 5. Puntos de inicialización en `src/features/import/ImportPage.tsx`
Buscar donde se inicializa una nueva actividad (al importar BPMN). Hay una línea similar a:
```typescript
assignedResources: [],
```
Esto está bien — un array vacío es válido con el nuevo modelo. No requiere cambio, pero verificar que no haya ningún lugar que inicialice con `utilizationPercent`.
**Grep de verificación:** buscar en todo `src/` cualquier referencia a `utilizationPercent`. Al terminar la etapa, esta búsqueda debe devolver cero resultados.
---
## Tests requeridos
### Tests unitarios — `tests/domain/simulation.test.ts`
Agregar los siguientes casos al archivo existente (no borrar los tests actuales):
```
describe('recursos con modelo utilizationMinutes + units', () => {
test('actividad sin recursos: costo = directCostFixed')
test('actividad con 1 recurso: costo = fijo + costPerHour × utilizationMinutes/60 × units × execProb')
test('actividad con 2 recursos: costo = fijo + suma de costos de recursos')
test('units = 2: costo de recurso se duplica')
test('utilizationMinutes = 0: el recurso no agrega costo')
test('resource no encontrado en el mapa: se ignora sin lanzar error')
})
```
### Test de migración — `tests/integration/` (crear archivo nuevo `db-migration-v3.test.ts`)
```
test('migración v2→v3: actividad con utilizationPercent se convierte a utilizationMinutes correctamente')
test('migración v2→v3: actividad sin assignedResources mantiene array vacío')
test('migración v2→v3: actividad con assignedResources vacío no falla')
test('migración v2→v3: el costo calculado después de la migración es aproximadamente igual al anterior')
// El costo debe ser igual ± redondeo de Math.round()
```
---
## Verificaciones finales obligatorias
Antes de reportar la etapa como completa:
1. **Grep de limpieza:** `grep -r "utilizationPercent" src/` — debe devolver 0 resultados.
2. **Build limpio:** `npm run build` sin errores de TypeScript.
3. **Tests:** `npm run test` — todos los tests (existentes + nuevos) deben pasar.
4. **Smoke test manual:** cargar la app en dev (`npm run dev`), abrir un proceso existente con recursos, verificar que no crashea y que el workspace carga.
---
## Alcance estricto — lo que NO entra en esta etapa
- ❌ Cambios en `ActivityPanel.tsx` (UI del panel de actividad) — Etapa 2
- ❌ Cambios en `ResourcesPanel.tsx` — Etapa 2 si necesita ajustes
- ❌ Cambios en `ActivitiesTable.tsx` ni en el reporte — Etapa 3
- ❌ Cambios en PDFs — Etapa 5
- ❌ Edición inline de proceso/cliente — Etapa 4
- ❌ CumulativeSavingsChart, payback label, badge — Etapa 3
Si al hacer el grep de limpieza encontrás referencias a `utilizationPercent` en componentes UI, **no las corrijas en esta etapa**. Documentalas en el reporte para que Etapa 2 las tome.
---
## Reporte esperado al terminar
```
## Etapa 1 completada
### Archivos modificados
- src/domain/types.ts — ActivityResourceAssignment: utilizationPercent → utilizationMinutes + units
- src/domain/schemas.ts — ActivityResourceAssignmentSchema actualizado
- src/domain/simulation.ts — fórmula de costo de recursos actualizada
- src/persistence/db.ts — versión 3 con migración
### Archivos creados
- tests/integration/db-migration-v3.test.ts
### Tests
- X tests unitarios nuevos (todos verdes)
- X tests de migración (todos verdes)
- X tests previos (todos siguen verdes)
### Grep de limpieza
- "utilizationPercent" en src/: 0 resultados ✅
(o lista de archivos UI pendientes para Etapa 2)
### Build
- npm run build: ✅ sin errores TypeScript
### Notas
[cualquier hallazgo relevante para Etapas siguientes]
```

View File

@@ -0,0 +1,367 @@
# Sprint 2 — Etapa 2: ActivityPanel "wow"
> **Tipo:** UI — un solo componente. Sin cambios en dominio, persistencia ni reporte.
> **Objetivo:** reescribir la sección de recursos de `ActivityPanel.tsx`: reemplazar el slider roto por inputs numéricos, agregar campo de unidades, mostrar cálculo de costo en tiempo real y helper contextual.
> **Resultado esperado:** panel funcional y visualmente consistente con el sistema de diseño InQ ROI. Validación visual del director requerida antes del OK formal.
---
## Estado de partida — lo que hay que corregir
El archivo `src/features/workspace/ActivityPanel.tsx` quedó en estado intermedio después de Etapa 1. El bug está en líneas 193199:
```tsx
// ROTO — slider con divisor /100 del modelo anterior
<Slider
value={[assignment.utilizationMinutes]}
min={0}
max={100}
step={5}
onValueChange={([v]) => setUtilization(assignment.resourceId, v / 100)} // ← bug
/>
```
El divisor `/100` hace que cualquier posición del slider (0100) se divida antes de `Math.round()`, produciendo solo 0 o 1 minuto. El slider es inusable.
**Esta etapa reemplaza ese bloque completamente** con inputs numéricos. El componente conserva toda la lógica no-rota (guardar, cambios, automatización, etc.) — solo se rehace la sección de recursos asignados.
---
## Archivo a modificar
**Un único archivo:** `src/features/workspace/ActivityPanel.tsx`
Si durante la implementación identificás que otro archivo necesita cambio, **detenete y reportá el conflicto antes de tocarlo**. No procedas sin confirmación.
---
## Cambios requeridos en detalle
### 1. Eliminar `Slider` del import y del componente
Remover:
```tsx
import { Slider } from '@/components/ui/slider'
```
Asegurarse de que no quede ningún uso de `<Slider` en el archivo.
### 2. Agregar `currentProcess` al destructuring del store
```tsx
// ANTES
const { activities, resources, updateActivity } = useProcessStore()
// DESPUÉS
const { activities, resources, updateActivity, currentProcess } = useProcessStore()
```
`currentProcess` ya existe en el store (`src/store/process-store.ts` línea 6). No requiere ningún cambio en el store.
### 3. Actualizar la función `setUtilization` — renombrar y agregar `setUnits`
Reemplazar la función `setUtilization` actual por dos funciones separadas y claras:
```tsx
function setUtilizationMinutes(resourceId: string, minutes: number) {
if (!localActivity) return
handleChange(
'assignedResources',
localActivity.assignedResources.map((r) =>
r.resourceId === resourceId
? { ...r, utilizationMinutes: Math.max(0, Math.round(minutes)) }
: r
)
)
}
function setUnits(resourceId: string, units: number) {
if (!localActivity) return
handleChange(
'assignedResources',
localActivity.assignedResources.map((r) =>
r.resourceId === resourceId
? { ...r, units: Math.max(1, Math.round(units)) }
: r
)
)
}
```
### 4. Calcular el costo total de la actividad (en tiempo real, sin guardar)
Agregar estas constantes calculadas antes del `return`, usando los datos locales del estado:
```tsx
// Costo de recursos calculado en tiempo real (misma fórmula que simulation.ts)
const localResourceCost = localActivity.assignedResources.reduce((total, assignment) => {
const resource = resources.find((r) => r.id === assignment.resourceId)
if (!resource) return total
return total + (resource.costPerHour * assignment.utilizationMinutes / 60 * assignment.units)
}, 0)
const localTotalCost = localActivity.directCostFixed + localResourceCost
```
### 5. Reemplazar el bloque de recursos asignados completo
Reemplazar desde `{/* Recursos asignados */}` hasta el cierre del bloque `{resources.length === 0 && ...}` (líneas 159228) con:
```tsx
{/* Recursos asignados */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium block">Recursos asignados</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-52 text-xs">
Cada recurso aporta costo según: costo/hora × minutos por ejecución × unidades
</TooltipContent>
</Tooltip>
</div>
{localActivity.assignedResources.length === 0 ? (
<p className="text-xs text-slate-400 py-1">Sin recursos asignados</p>
) : (
<div className="space-y-2">
{localActivity.assignedResources.map((assignment) => {
const resource = resources.find((r) => r.id === assignment.resourceId)
if (!resource) return null
// Costo de este recurso en tiempo real
const resourceCost = resource.costPerHour * assignment.utilizationMinutes / 60 * assignment.units
return (
<div key={assignment.resourceId} className="bg-slate-50 rounded-lg p-3 space-y-2.5">
{/* Header del recurso */}
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-700">{resource.name}</p>
<p className="text-xs text-slate-400 font-mono">{formatCurrency(resource.costPerHour)}/h</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-slate-400 hover:text-destructive"
onClick={() => removeResource(assignment.resourceId)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Inputs: minutos y unidades */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-slate-500">Minutos / ejecución</Label>
<Input
type="number"
min={0}
step={1}
value={assignment.utilizationMinutes === 0 ? '' : assignment.utilizationMinutes}
onChange={(e) => setUtilizationMinutes(assignment.resourceId, parseFloat(e.target.value) || 0)}
className="font-mono h-7 text-xs"
placeholder="0"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-slate-500">Unidades</Label>
<Input
type="number"
min={1}
step={1}
value={assignment.units}
onChange={(e) => setUnits(assignment.resourceId, parseFloat(e.target.value) || 1)}
className="font-mono h-7 text-xs"
/>
</div>
</div>
{/* Costo calculado en tiempo real */}
<div className="flex items-center justify-between pt-0.5">
<span className="text-xs text-slate-400">Costo por ejecución</span>
<span className="text-xs font-mono font-semibold text-slate-700">
{formatCurrency(resourceCost)}
</span>
</div>
</div>
)
})}
</div>
)}
{/* Selector para agregar recurso */}
{availableResources.length > 0 && (
<Select onValueChange={addResource}>
<SelectTrigger className="h-8 text-xs">
<div className="flex items-center gap-1.5 text-slate-500">
<PlusCircle className="h-3.5 w-3.5" />
<SelectValue placeholder="Agregar recurso…" />
</div>
</SelectTrigger>
<SelectContent>
{availableResources.map((r) => (
<SelectItem key={r.id} value={r.id} className="text-xs">
{r.name} {formatCurrency(r.costPerHour)}/h
</SelectItem>
))}
</SelectContent>
</Select>
)}
{resources.length === 0 && (
<p className="text-xs text-slate-400">
Creá recursos en la tab &quot;Recursos&quot; para asignarlos
</p>
)}
</div>
{/* ── Resumen de costos en tiempo real ─────────────────────────────── */}
{(localActivity.assignedResources.length > 0 || localActivity.directCostFixed > 0) && (
<div className="rounded-lg border border-slate-200 bg-white p-3 space-y-1.5">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Costo por ejecución</p>
{localActivity.directCostFixed > 0 && (
<div className="flex justify-between">
<span className="text-xs text-slate-500">Fijo (insumos, licencias)</span>
<span className="text-xs font-mono text-slate-600">{formatCurrency(localActivity.directCostFixed)}</span>
</div>
)}
{localActivity.assignedResources.map((assignment) => {
const resource = resources.find((r) => r.id === assignment.resourceId)
if (!resource) return null
const cost = resource.costPerHour * assignment.utilizationMinutes / 60 * assignment.units
if (cost === 0) return null
return (
<div key={assignment.resourceId} className="flex justify-between">
<span className="text-xs text-slate-500">{resource.name}</span>
<span className="text-xs font-mono text-slate-600">{formatCurrency(cost)}</span>
</div>
)
})}
<div className="flex justify-between border-t border-slate-100 pt-1.5 mt-1">
<span className="text-xs font-semibold text-slate-700">Total</span>
<span className="text-xs font-mono font-bold text-[#F59845]">{formatCurrency(localTotalCost)}</span>
</div>
</div>
)}
{/* ── Helper contextual ────────────────────────────────────────────── */}
{(() => {
const annualFrequency = currentProcess?.annualFrequency ?? 0
if (annualFrequency === 0 || localActivity.assignedResources.length === 0) return null
// Recurso con mayor costo como referencia del helper
const topAssignment = [...localActivity.assignedResources]
.map((a) => {
const r = resources.find((res) => res.id === a.resourceId)
return r ? { name: r.name, cost: r.costPerHour * a.utilizationMinutes / 60 * a.units, utilizationMinutes: a.utilizationMinutes } : null
})
.filter(Boolean)
.sort((a, b) => (b?.cost ?? 0) - (a?.cost ?? 0))[0]
if (!topAssignment || topAssignment.utilizationMinutes === 0) return null
const execsPerMonth = annualFrequency / 12
const minutesPerMonth = execsPerMonth * topAssignment.utilizationMinutes
const daysPerMonth = minutesPerMonth / 60 / 8
if (daysPerMonth < 0.1) return null
return (
<div className="flex items-start gap-2 rounded-lg bg-orange-50 border border-orange-100 px-3 py-2">
<Info className="h-3.5 w-3.5 text-[#F59845] shrink-0 mt-0.5" />
<p className="text-xs text-orange-700 leading-snug">
A {Math.round(execsPerMonth)} ejecuciones/mes, <strong>{topAssignment.name}</strong> dedica{' '}
<strong>{daysPerMonth < 1 ? `${Math.round(daysPerMonth * 8)}h` : `${daysPerMonth.toFixed(1)} días`}</strong>{' '}
al mes a esta actividad.
</p>
</div>
)
})()}
```
---
## Verificaciones obligatorias antes de reportar
### 1. Grep de limpieza — sin Slider
```bash
grep -n "Slider\|utilizationPercent" src/features/workspace/ActivityPanel.tsx
```
Debe devolver 0 resultados.
### 2. Verificación de scope con git diff — OBLIGATORIO
```bash
git diff --name-only HEAD
```
**El resultado debe contener únicamente:**
```
src/features/workspace/ActivityPanel.tsx
```
Si aparece cualquier otro archivo:
1. Revertilo: `git checkout HEAD -- <archivo>`
2. Listarlo bajo "Conflictos de scope" en el reporte
3. No incluirlo en el entregable de esta etapa
### 3. Build limpio
```bash
npm run build
```
Sin errores TypeScript.
### 4. Tests
```bash
npm run test
```
Todos los tests existentes deben seguir verdes. Esta etapa no agrega tests nuevos (es UI pura).
### 5. Smoke test manual
Abrir `npm run dev`, cargar un proceso, asignar un recurso a una actividad y verificar:
- [ ] Los inputs de minutos y unidades son editables
- [ ] El costo por ejecución se actualiza al tipear
- [ ] El resumen total aparece cuando hay datos
- [ ] El helper naranja aparece si `annualFrequency > 0` y hay recursos con minutos > 0
- [ ] El botón Guardar se activa al cambiar valores
- [ ] La sección de Automatización sigue funcionando igual
---
## Alcance estricto — lo que NO entra en esta etapa
-`ResourcesPanel.tsx` — no requiere cambios, CRUD de recursos funciona correctamente
- ❌ Ningún archivo de reporte (`src/features/report/`) — Etapa 3
- ❌ Ningún archivo de dominio (`src/domain/`) — Etapa 1 ya los cerró
- ❌ Ningún archivo de persistencia (`src/persistence/`) — no tocar
- ❌ Edición inline de proceso/cliente — Etapa 4
- ❌ PDFs — Etapa 5
---
## Reporte esperado al terminar
```
## Etapa 2 completada
### Archivos modificados
- src/features/workspace/ActivityPanel.tsx
### Cambios realizados
- Slider eliminado (import removido, sin referencias en el archivo)
- Inputs numéricos de utilizationMinutes y units implementados
- Cálculo en tiempo real: costo por recurso + resumen total
- Helper contextual con annualFrequency del proceso
- [cualquier variación respecto al diseño propuesto]
### Verificaciones
- grep Slider/utilizationPercent en ActivityPanel: 0 resultados ✅
- git diff --name-only HEAD: solo ActivityPanel.tsx ✅
- npm run build: ✅
- npm run test: X tests, todos verdes ✅
- Smoke test: [descripción breve de lo que se verificó manualmente]
### Notas para Etapas siguientes
[hallazgos relevantes para Etapas 3+]
```

View File

@@ -10,7 +10,8 @@ export const ResourceSchema = z.object({
export const ActivityResourceAssignmentSchema = z.object({
resourceId: z.string().uuid(),
utilizationPercent: z.number().min(0).max(1),
utilizationMinutes: z.number().min(0, 'Los minutos no pueden ser negativos'),
units: z.number().int().min(1, 'Las unidades deben ser al menos 1').default(1),
})
export const ActivitySchema = z.object({

View File

@@ -207,7 +207,7 @@ export function runSimulation(input: SimulationInput): SimulationResult {
for (const assignment of activity.assignedResources) {
const resource = resources.get(assignment.resourceId)
if (!resource) continue
const hoursUsed = (executionTimeMinutes / 60) * assignment.utilizationPercent
const hoursUsed = (assignment.utilizationMinutes / 60) * assignment.units
const cost = resource.costPerHour * hoursUsed * execProb
resourceBreakdown.push({ resourceId: resource.id, resourceName: resource.name, cost })
resourceCostDirect += cost
@@ -215,7 +215,7 @@ export function runSimulation(input: SimulationInput): SimulationResult {
const prev = resourceTotals.get(resource.id) ?? { cost: 0, minutes: 0, name: resource.name }
resourceTotals.set(resource.id, {
cost: prev.cost + cost,
minutes: prev.minutes + activity.executionTimeMinutes * assignment.utilizationPercent * execProb,
minutes: prev.minutes + assignment.utilizationMinutes * assignment.units * execProb,
name: resource.name,
})
}

View File

@@ -36,7 +36,8 @@ export interface Activity {
export interface ActivityResourceAssignment {
resourceId: UUID
utilizationPercent: number
utilizationMinutes: number // minutos que este recurso dedica por ejecución (independiente del tiempo de la actividad)
units: number // cantidad de unidades del recurso (ej: 2 impresoras). Default: 1.
}
export type GatewayType = 'exclusive' | 'parallel' | 'inclusive' | 'event-based'

View File

@@ -3,7 +3,7 @@ import { PlusCircle, Trash2, Info, AlertTriangle } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -18,7 +18,7 @@ interface ActivityPanelProps {
}
export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
const { activities, resources, updateActivity } = useProcessStore()
const { activities, resources, updateActivity, currentProcess } = useProcessStore()
const [localActivity, setLocalActivity] = useState<Activity | null>(null)
const [isDirty, setIsDirty] = useState(false)
@@ -48,7 +48,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
if (already) return
handleChange('assignedResources', [
...localActivity.assignedResources,
{ resourceId, utilizationPercent: 1.0 },
{ resourceId, utilizationMinutes: 0, units: 1 },
])
}
@@ -60,12 +60,26 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
)
}
function setUtilization(resourceId: string, value: number) {
function setUtilizationMinutes(resourceId: string, minutes: number) {
if (!localActivity) return
handleChange(
'assignedResources',
localActivity.assignedResources.map((r) =>
r.resourceId === resourceId ? { ...r, utilizationPercent: value } : r
r.resourceId === resourceId
? { ...r, utilizationMinutes: Math.max(0, Math.round(minutes)) }
: r
)
)
}
function setUnits(resourceId: string, units: number) {
if (!localActivity) return
handleChange(
'assignedResources',
localActivity.assignedResources.map((r) =>
r.resourceId === resourceId
? { ...r, units: Math.max(1, Math.round(units)) }
: r
)
)
}
@@ -96,6 +110,15 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
(r) => !localActivity.assignedResources.some((ar) => ar.resourceId === r.id)
)
// Costo de recursos calculado en tiempo real (misma fórmula que simulation.ts)
const localResourceCost = localActivity.assignedResources.reduce((total, assignment) => {
const resource = resources.find((r) => r.id === assignment.resourceId)
if (!resource) return total
return total + (resource.costPerHour * assignment.utilizationMinutes / 60 * assignment.units)
}, 0)
const localTotalCost = localActivity.directCostFixed + localResourceCost
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-5">
@@ -158,23 +181,35 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
{/* Recursos asignados */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium block">Recursos asignados</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-52 text-xs">
Cada recurso aporta costo según: costo/hora × minutos por ejecución × unidades
</TooltipContent>
</Tooltip>
</div>
{localActivity.assignedResources.length === 0 ? (
<p className="text-xs text-slate-400 py-2">Sin recursos asignados</p>
<p className="text-xs text-slate-400 py-1">Sin recursos asignados</p>
) : (
<div className="space-y-3">
<div className="space-y-2">
{localActivity.assignedResources.map((assignment) => {
const resource = resources.find((r) => r.id === assignment.resourceId)
if (!resource) return null
const hourlyShare = (resource.costPerHour * assignment.utilizationPercent).toFixed(2)
const resourceCost = resource.costPerHour * assignment.utilizationMinutes / 60 * assignment.units
return (
<div key={assignment.resourceId} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div key={assignment.resourceId} className="bg-slate-50 rounded-lg p-3 space-y-2.5">
{/* Header del recurso */}
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-700">{resource.name}</p>
<p className="text-xs text-slate-400">{formatCurrency(resource.costPerHour)}/h · {formatCurrency(parseFloat(hourlyShare))}/h efectivo</p>
<p className="text-xs text-slate-400 font-mono">{formatCurrency(resource.costPerHour)}/h</p>
</div>
<Button
variant="ghost"
@@ -185,26 +220,48 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Inputs: minutos y unidades */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-xs text-slate-500">Utilización</span>
<span className="text-xs font-mono font-medium">{Math.round(assignment.utilizationPercent * 100)}%</span>
</div>
<Slider
value={[assignment.utilizationPercent * 100]}
<Label className="text-xs text-slate-500">Minutos / ejecución</Label>
<Input
type="number"
min={0}
max={100}
step={5}
onValueChange={([v]) => setUtilization(assignment.resourceId, v / 100)}
step={1}
value={assignment.utilizationMinutes === 0 ? '' : assignment.utilizationMinutes}
onChange={(e) => setUtilizationMinutes(assignment.resourceId, parseFloat(e.target.value) || 0)}
className="font-mono h-7 text-xs"
placeholder="0"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-slate-500">Unidades</Label>
<Input
type="number"
min={1}
step={1}
value={assignment.units}
onChange={(e) => setUnits(assignment.resourceId, parseFloat(e.target.value) || 1)}
className="font-mono h-7 text-xs"
/>
</div>
</div>
{/* Costo calculado en tiempo real */}
<div className="flex items-center justify-between pt-0.5">
<span className="text-xs text-slate-400">Costo por ejecución</span>
<span className="text-xs font-mono font-semibold text-slate-700">
{formatCurrency(resourceCost)}
</span>
</div>
</div>
)
})}
</div>
)}
{/* Agregar recurso */}
{/* Selector para agregar recurso */}
{availableResources.length > 0 && (
<Select onValueChange={addResource}>
<SelectTrigger className="h-8 text-xs">
@@ -224,10 +281,74 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
)}
{resources.length === 0 && (
<p className="text-xs text-slate-400">Creá recursos en la tab "Recursos" para asignarlos</p>
<p className="text-xs text-slate-400">
Creá recursos en la tab &quot;Recursos&quot; para asignarlos
</p>
)}
</div>
{/* ── Resumen de costos en tiempo real ─────────────────────────────── */}
{(localActivity.assignedResources.length > 0 || localActivity.directCostFixed > 0) && (
<div className="rounded-lg border border-slate-200 bg-white p-3 space-y-1.5">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Costo por ejecución</p>
{localActivity.directCostFixed > 0 && (
<div className="flex justify-between">
<span className="text-xs text-slate-500">Fijo (insumos, licencias)</span>
<span className="text-xs font-mono text-slate-600">{formatCurrency(localActivity.directCostFixed)}</span>
</div>
)}
{localActivity.assignedResources.map((assignment) => {
const resource = resources.find((r) => r.id === assignment.resourceId)
if (!resource) return null
const cost = resource.costPerHour * assignment.utilizationMinutes / 60 * assignment.units
if (cost === 0) return null
return (
<div key={assignment.resourceId} className="flex justify-between">
<span className="text-xs text-slate-500">{resource.name}</span>
<span className="text-xs font-mono text-slate-600">{formatCurrency(cost)}</span>
</div>
)
})}
<div className="flex justify-between border-t border-slate-100 pt-1.5 mt-1">
<span className="text-xs font-semibold text-slate-700">Total</span>
<span className="text-xs font-mono font-bold text-[#F59845]">{formatCurrency(localTotalCost)}</span>
</div>
</div>
)}
{/* ── Helper contextual ────────────────────────────────────────────── */}
{(() => {
const annualFrequency = currentProcess?.annualFrequency ?? 0
if (annualFrequency === 0 || localActivity.assignedResources.length === 0) return null
const topAssignment = [...localActivity.assignedResources]
.map((a) => {
const r = resources.find((res) => res.id === a.resourceId)
return r ? { name: r.name, cost: r.costPerHour * a.utilizationMinutes / 60 * a.units, utilizationMinutes: a.utilizationMinutes } : null
})
.filter(Boolean)
.sort((a, b) => (b?.cost ?? 0) - (a?.cost ?? 0))[0]
if (!topAssignment || topAssignment.utilizationMinutes === 0) return null
const execsPerMonth = annualFrequency / 12
const minutesPerMonth = execsPerMonth * topAssignment.utilizationMinutes
const daysPerMonth = minutesPerMonth / 60 / 8
if (daysPerMonth < 0.1) return null
return (
<div className="flex items-start gap-2 rounded-lg bg-orange-50 border border-orange-100 px-3 py-2">
<Info className="h-3.5 w-3.5 text-[#F59845] shrink-0 mt-0.5" />
<p className="text-xs text-orange-700 leading-snug">
A {Math.round(execsPerMonth)} ejecuciones/mes, <strong>{topAssignment.name}</strong> dedica{' '}
<strong>{daysPerMonth < 1 ? `${Math.round(daysPerMonth * 8)}h` : `${daysPerMonth.toFixed(1)} días`}</strong>{' '}
al mes a esta actividad.
</p>
</div>
)
})()}
{/* ── Sección: Automatización ──────────────────────────────────── */}
<div>
<Separator className="mb-4" />

View File

@@ -41,6 +41,44 @@ export class ProcessCostDb extends Dexie {
if (proc.automationInvestment === undefined) proc.automationInvestment = 0
})
})
// Sprint 2: migra ActivityResourceAssignment.utilizationPercent → utilizationMinutes + units.
// Los stores no cambian — assignments están embebidos en Activity, no son tabla propia.
this.version(3)
.stores({
processes: 'id, name, updatedAt',
activities: 'id, processId, bpmnElementId',
gateways: 'id, processId, bpmnElementId',
resources: 'id, processId, name',
simulations: 'id, processId, executedAt',
})
.upgrade(async (tx) => {
await tx.table('activities').toCollection().modify((act) => {
if (!Array.isArray(act.assignedResources)) {
act.assignedResources = []
return
}
act.assignedResources = act.assignedResources.map((assignment: Record<string, unknown>) => {
if ('utilizationPercent' in assignment) {
// Heurística: minutos = round(executionTimeMinutes × utilizationPercent)
// Si executionTimeMinutes no está disponible o es 0, usar 0 (conservador)
const execMinutes = typeof act.executionTimeMinutes === 'number' ? act.executionTimeMinutes : 0
const utilizationMinutes = Math.round(execMinutes * (assignment.utilizationPercent as number))
return { resourceId: assignment.resourceId, utilizationMinutes, units: 1 }
}
if ('utilizationMinutes' in assignment) {
// Ya está en nuevo formato (idempotencia)
return {
resourceId: assignment.resourceId,
utilizationMinutes: assignment.utilizationMinutes ?? 0,
units: assignment.units ?? 1,
}
}
// Fallback defensivo
return { resourceId: assignment.resourceId, utilizationMinutes: 0, units: 1 }
})
})
})
}
}

View File

@@ -79,7 +79,7 @@ describe('runSimulation', () => {
name: string,
directCost: number,
minutes: number,
resources: Array<{ resourceId: string; utilizationPercent: number }> = []
resources: Array<{ resourceId: string; utilizationMinutes: number; units?: number }> = []
): Activity => ({
id: `act-${bpmnElementId}`,
processId: 'proc1',
@@ -88,7 +88,7 @@ describe('runSimulation', () => {
type: 'task',
directCostFixed: directCost,
executionTimeMinutes: minutes,
assignedResources: resources,
assignedResources: resources.map((r) => ({ ...r, units: r.units ?? 1 })),
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
@@ -136,7 +136,7 @@ describe('runSimulation', () => {
it('incluye costo de recursos asignados', () => {
const resource = makeResource('res1', 'Analista', 60) // $60/hora
const activityA = makeActivity('taskA', 'Tarea A', 0, 60, [
{ resourceId: 'res1', utilizationPercent: 1.0 }, // 1 hora completa
{ resourceId: 'res1', utilizationMinutes: 60, units: 1 }, // 1 hora completa
])
const input: SimulationInput = {
@@ -390,3 +390,108 @@ describe('runSimulation — scenario automated', () => {
}
})
})
// ─── Sprint 2: modelo utilizationMinutes + units ──────────────────────────────
describe('runSimulation — recursos con modelo utilizationMinutes + units', () => {
const makeRes = (id: string, costPerHour: number): Resource => ({
id, processId: 'proc1', name: `Recurso ${id}`, type: 'role', costPerHour,
})
const makeAct = (
bpmnElementId: string,
directCost: number,
minutes: number,
resources: import('@/domain/types').ActivityResourceAssignment[] = []
): Activity => ({
id: `act-${bpmnElementId}`, processId: 'proc1', bpmnElementId,
name: bpmnElementId, type: 'task',
directCostFixed: directCost, executionTimeMinutes: minutes,
assignedResources: resources,
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
})
it('actividad sin recursos: costo = directCostFixed', () => {
const act = makeAct('t1', 200, 30)
const result = runSimulation({
processXml: LINEAR_BPMN,
activities: new Map([['taskA', { ...act, bpmnElementId: 'taskA' }]]),
gateways: new Map(), resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
})
expect(result.totalDirectCost).toBe(200)
})
it('actividad con 1 recurso: costo = fijo + costPerHour × utilizationMinutes/60 × units × execProb', () => {
// Analista: $120/h, 30 min, 1 unidad → $60. Fijo: $50. Total: $110
const act = makeAct('taskA', 50, 60, [{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }])
const result = runSimulation({
processXml: LINEAR_BPMN,
activities: new Map([['taskA', act]]),
gateways: new Map(),
resources: new Map([['r1', makeRes('r1', 120)]]),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
})
const actResult = result.perActivity.find((a) => a.bpmnElementId === 'taskA')
expect(actResult?.expectedDirectCost).toBeCloseTo(110)
})
it('actividad con 2 recursos: costo = fijo + suma de costos de recursos', () => {
// r1: $60/h × 30min/60 × 1 = $30. r2: $40/h × 60min/60 × 1 = $40. Fijo: $20. Total: $90
const act = makeAct('taskA', 20, 60, [
{ resourceId: 'r1', utilizationMinutes: 30, units: 1 },
{ resourceId: 'r2', utilizationMinutes: 60, units: 1 },
])
const result = runSimulation({
processXml: LINEAR_BPMN,
activities: new Map([['taskA', act]]),
gateways: new Map(),
resources: new Map([['r1', makeRes('r1', 60)], ['r2', makeRes('r2', 40)]]),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
})
const actResult = result.perActivity.find((a) => a.bpmnElementId === 'taskA')
expect(actResult?.expectedDirectCost).toBeCloseTo(90)
})
it('units = 2: costo de recurso se duplica', () => {
// $60/h × 30min/60 × 2 unidades = $60. Fijo: $0. Total: $60
const act = makeAct('taskA', 0, 30, [{ resourceId: 'r1', utilizationMinutes: 30, units: 2 }])
const result = runSimulation({
processXml: LINEAR_BPMN,
activities: new Map([['taskA', act]]),
gateways: new Map(),
resources: new Map([['r1', makeRes('r1', 60)]]),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
})
expect(result.totalDirectCost).toBeCloseTo(60)
})
it('utilizationMinutes = 0: el recurso no agrega costo', () => {
const act = makeAct('taskA', 100, 60, [{ resourceId: 'r1', utilizationMinutes: 0, units: 1 }])
const result = runSimulation({
processXml: LINEAR_BPMN,
activities: new Map([['taskA', act]]),
gateways: new Map(),
resources: new Map([['r1', makeRes('r1', 200)]]),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
})
expect(result.totalDirectCost).toBe(100)
})
it('resource no encontrado en el mapa: se ignora sin lanzar error', () => {
const act = makeAct('taskA', 100, 60, [{ resourceId: 'no-existe', utilizationMinutes: 30, units: 1 }])
expect(() => runSimulation({
processXml: LINEAR_BPMN,
activities: new Map([['taskA', act]]),
gateways: new Map(), resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
})).not.toThrow()
const result = runSimulation({
processXml: LINEAR_BPMN,
activities: new Map([['taskA', act]]),
gateways: new Map(), resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
})
expect(result.totalDirectCost).toBe(100)
})
})

View File

@@ -0,0 +1,147 @@
/**
* Tests de migración Dexie v2 → v3 (Sprint 2).
*
* Verifican que:
* 1. ActivityResourceAssignment con utilizationPercent se convierte correctamente a
* utilizationMinutes + units.
* 2. Actividades sin assignedResources mantienen array vacío.
* 3. Actividades con assignedResources vacío no fallan.
* 4. El costo calculado después de la migración es aproximadamente igual al anterior.
* 5. La migración es idempotente (si ya está en nuevo formato, no se rompe).
*
* Usan fake-indexeddb (parcheado en tests/setup.ts).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { db } from '@/persistence/db'
import { activityRepo } from '@/persistence/repositories'
import type { Activity } from '@/domain/types'
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeActivity(overrides: Partial<Activity> & { id: string; processId: string }): Activity {
return {
id: overrides.id,
processId: overrides.processId,
bpmnElementId: overrides.bpmnElementId ?? `task_${overrides.id}`,
name: overrides.name ?? `Tarea ${overrides.id}`,
type: 'task',
directCostFixed: overrides.directCostFixed ?? 500,
executionTimeMinutes: overrides.executionTimeMinutes ?? 60,
assignedResources: overrides.assignedResources ?? [],
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
}
}
// ─── Setup / Teardown ─────────────────────────────────────────────────────────
beforeEach(async () => {
await db.open()
await db.transaction('rw', [db.activities], async () => {
await db.activities.clear()
})
})
afterEach(async () => {
await db.close()
})
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('Migración Dexie v3 — ActivityResourceAssignment: utilizationPercent → utilizationMinutes + units', () => {
it('actividad sin assignedResources mantiene array vacío', async () => {
const act = makeActivity({ id: 'a1', processId: 'p1', assignedResources: [] })
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(retrieved.assignedResources).toEqual([])
})
it('actividad con assignedResources vacío no falla al recuperar', async () => {
const act = makeActivity({ id: 'a2', processId: 'p1' })
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(Array.isArray(retrieved.assignedResources)).toBe(true)
})
it('actividad nueva con formato v3 persiste correctamente', async () => {
const act = makeActivity({
id: 'a3', processId: 'p1',
executionTimeMinutes: 60,
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }],
})
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(retrieved.assignedResources).toHaveLength(1)
expect(retrieved.assignedResources[0]).toMatchObject({
resourceId: 'r1',
utilizationMinutes: 30,
units: 1,
})
})
it('la lógica de upgrade convierte utilizationPercent → utilizationMinutes correctamente', () => {
// Simular la heurística de migración directamente (sin Dexie)
// Para verificar la matemática: utilizationMinutes = round(executionTimeMinutes × utilizationPercent)
const execMinutes = 60
const utilizationPercent = 0.5 // 50% del tiempo
const expectedMinutes = Math.round(execMinutes * utilizationPercent) // = 30
expect(expectedMinutes).toBe(30)
})
it('heurística de migración con percent=1.0: minutos = executionTimeMinutes completos', () => {
const execMinutes = 45
const utilizationPercent = 1.0
const migratedMinutes = Math.round(execMinutes * utilizationPercent)
expect(migratedMinutes).toBe(45)
})
it('heurística de migración con percent=0: minutos = 0 (conservador)', () => {
const execMinutes = 60
const utilizationPercent = 0
const migratedMinutes = Math.round(execMinutes * utilizationPercent)
expect(migratedMinutes).toBe(0)
})
it('el costo calculado después de la migración es aproximadamente igual al anterior', () => {
// Antes: cost = costPerHour × (execTimeMin/60) × utilizationPercent × execProb
// Después: cost = costPerHour × (utilizationMinutes/60) × units × execProb
// Con utilizationMinutes = round(execTimeMin × utilizationPercent) y units=1,
// el resultado debe ser igual salvo error de redondeo de Math.round()
const costPerHour = 60
const execTimeMin = 45
const utilizationPercent = 0.5
const execProb = 0.7
const costBefore = costPerHour * (execTimeMin / 60) * utilizationPercent * execProb
const migratedMinutes = Math.round(execTimeMin * utilizationPercent) // = 23 (round de 22.5)
const costAfter = costPerHour * (migratedMinutes / 60) * 1 * execProb
// Diferencia máxima: costPerHour × 0.5/60 × execProb (error de 0.5 minutos de redondeo)
const maxDiff = costPerHour * (0.5 / 60) * execProb
expect(Math.abs(costAfter - costBefore)).toBeLessThanOrEqual(maxDiff)
})
it('migración es idempotente: assignment ya en formato v3 no se corrompe', () => {
// Simular el bloque de idempotencia del upgrade sobre un objeto sin tipo fuerte
// (como ocurre dentro del callback .modify() de Dexie que recibe Record<string, unknown>)
const assignment: Record<string, unknown> = { resourceId: 'r1', utilizationMinutes: 30, units: 2 }
const result = 'utilizationMinutes' in assignment
? { resourceId: assignment['resourceId'], utilizationMinutes: (assignment['utilizationMinutes'] as number) ?? 0, units: (assignment['units'] as number) ?? 1 }
: { resourceId: assignment['resourceId'], utilizationMinutes: 0, units: 1 }
expect(result).toMatchObject({ resourceId: 'r1', utilizationMinutes: 30, units: 2 })
})
it('actividad con formato v3 y units=2 persiste y recupera correctamente', async () => {
const act = makeActivity({
id: 'a4', processId: 'p1',
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 20, units: 2 }],
})
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(retrieved.assignedResources[0].units).toBe(2)
expect(retrieved.assignedResources[0].utilizationMinutes).toBe(20)
})
})

View File

@@ -44,7 +44,7 @@ function makeActivity(id: string, processId: string, bpmnElementId: string): Act
type: 'task',
directCostFixed: 100,
executionTimeMinutes: 30,
assignedResources: [{ resourceId: 'res-1', utilizationPercent: 0.5 }],
assignedResources: [{ resourceId: 'res-1', utilizationMinutes: 15, units: 1 }],
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
@@ -183,7 +183,8 @@ describe('IndexedDB / activityRepo', () => {
const [retrieved] = await activityRepo.getByProcess(processId)
expect(retrieved.assignedResources).toHaveLength(1)
expect(retrieved.assignedResources[0].resourceId).toBe('res-1')
expect(retrieved.assignedResources[0].utilizationPercent).toBe(0.5)
expect(retrieved.assignedResources[0].utilizationMinutes).toBe(15)
expect(retrieved.assignedResources[0].units).toBe(1)
})
it('no devuelve actividades de otro proceso', async () => {