Sprint 2 Etapas 1-2: modelo recursos utilizationMinutes+units, Dexie v3, ActivityPanel wow
This commit is contained in:
295
CLAUDE.md
295
CLAUDE.md
@@ -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.
|
||||
|
||||
@@ -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.0–1.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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
113784
assets/2026. CONCILIA UPDATED.pdf
Normal file
113784
assets/2026. CONCILIA UPDATED.pdf
Normal file
File diff suppressed because one or more lines are too long
151076
assets/INQ ROCKETBOT PRESENTACION AGENTES.pdf
Normal file
151076
assets/INQ ROCKETBOT PRESENTACION AGENTES.pdf
Normal file
File diff suppressed because one or more lines are too long
25763
assets/InQ manual de marca- 3.0.pdf
Normal file
25763
assets/InQ manual de marca- 3.0.pdf
Normal file
File diff suppressed because it is too large
Load Diff
BIN
assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 BCO.png
Normal file
BIN
assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 BCO.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png
Normal file
BIN
assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
assets/MARCA FINAL 2023 AREA DE SEGURIDAD 2 BCO.png
Normal file
BIN
assets/MARCA FINAL 2023 AREA DE SEGURIDAD 2 BCO.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
assets/MARCA FINAL 2023 AREA DE SEGURIDAD 2 COLOR.png
Normal file
BIN
assets/MARCA FINAL 2023 AREA DE SEGURIDAD 2 COLOR.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
115
cowork/InQ ROI - Simulador de procesos/CLAUDE.md
Normal file
115
cowork/InQ ROI - Simulador de procesos/CLAUDE.md
Normal 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
311
sprints/sprint-2/BRIEF.md
Normal 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) | 60–70 % |
|
||||
| PDF (refinamientos, nuevas secciones) | 15–25 % |
|
||||
| Infraestructura / deuda técnica | 10–15 % |
|
||||
| Documentación / metodología | 5–10 % |
|
||||
|
||||
### 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 (0–1) }` |
|
||||
| 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 // 0–1, 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 A–E: 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
|
||||
259
sprints/sprint-2/ETAPA_1_PROMPT.md
Normal file
259
sprints/sprint-2/ETAPA_1_PROMPT.md
Normal 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 2–3.
|
||||
|
||||
---
|
||||
|
||||
## 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 // 0–1, 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]
|
||||
```
|
||||
367
sprints/sprint-2/ETAPA_2_PROMPT.md
Normal file
367
sprints/sprint-2/ETAPA_2_PROMPT.md
Normal 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 193–199:
|
||||
|
||||
```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 (0–100) 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 159–228) 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 "Recursos" 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+]
|
||||
```
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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">
|
||||
<Label className="text-xs font-medium block">Recursos asignados</Label>
|
||||
<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,18 +220,40 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
<Slider
|
||||
value={[assignment.utilizationPercent * 100]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
onValueChange={([v]) => setUtilization(assignment.resourceId, v / 100)}
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
@@ -204,7 +261,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
</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 "Recursos" 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" />
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
147
tests/integration/db-migration-v3.test.ts
Normal file
147
tests/integration/db-migration-v3.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user