From 67e259e6ed8aa1c17913cf7f82039603fb5b2286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Ben=C3=ADtez?= Date: Wed, 27 May 2026 11:49:43 -0300 Subject: [PATCH] feat(sprint-3): biblioteca, recursos TO-BE, ROI mejorado e identidad de marca MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 3 completo — 7 etapas: - Etapa 1-2: biblioteca de procesos con agrupación por cliente (Dexie v4, LibraryPage, library-store, groupId/tags en Process) - Etapa 3: modal de configuración global por proceso (horizonte, frecuencia, inversión, moneda) con validación y persistencia - Etapa 4: panel de recursos rediseñado con soporte de unidades y minutos de utilización (utilizationMinutes + units) - Etapa 5: recursos en escenario automatizado — motor de simulación, UI de edición en ActivityPanel, comparación AS-IS/TO-BE en reporte - Etapa 6: PDF recursos automatizados, automatedAssignedResources requerido, 561 tests pasando - Etapa 7: identidad de marca — AppHeader gradiente naranja/magenta con logo InQuality en 5 páginas, favicon con logo real, logo/marca navegan a home Co-Authored-By: Claude Sonnet 4.6 --- public/favicon.svg | 19 +- public/inquality-logo-color.png | Bin 0 -> 65309 bytes public/inquality-logo-white.png | Bin 0 -> 35242 bytes sprints/sprint-3/BRIEF.md | 440 +++++++++++++ sprints/sprint-3/ETAPA_1_PROMPT.md | 215 ++++++ sprints/sprint-3/ETAPA_2_PROMPT.md | 338 ++++++++++ sprints/sprint-3/ETAPA_3_PROMPT.md | 242 +++++++ sprints/sprint-3/ETAPA_4_PROMPT.md | 307 +++++++++ sprints/sprint-3/ETAPA_5_PROMPT.md | 445 +++++++++++++ sprints/sprint-3/ETAPA_6_PROMPT.md | 270 ++++++++ sprints/sprint-3/ETAPA_7_PROMPT.md | 267 ++++++++ src/components/AppFooter.tsx | 2 +- src/components/AppHeader.tsx | 39 ++ src/domain/simulation.ts | 6 +- src/domain/types.ts | 17 + src/features/import/ImportPage.tsx | 15 +- src/features/library/LibraryPage.tsx | 621 ++++++++++++++++++ src/features/report/ActivitiesTable.tsx | 64 +- src/features/report/ReportPage.tsx | 39 +- src/features/report/RoiKpiBar.tsx | 12 +- src/features/settings/SettingsPage.tsx | 82 +++ src/features/workspace/ActivityPanel.tsx | 142 +++- src/features/workspace/BpmnCanvas.tsx | 32 +- src/features/workspace/WorkspacePage.tsx | 107 ++- src/lib/export/pdf-export.ts | 2 +- src/lib/export/pdf-sections.ts | 7 +- src/persistence/db.ts | 42 +- src/persistence/repositories.ts | 49 +- src/router.tsx | 16 +- src/store/library-store.ts | 98 +++ src/store/process-store.ts | 69 +- src/store/simulation-store.ts | 20 +- tests/domain/simulation-sprint3.test.ts | 99 +++ tests/domain/simulation.test.ts | 4 + tests/features/report/components.test.tsx | 8 +- tests/features/report/derivations.test.ts | 2 +- tests/features/report/roi-components.test.tsx | 8 +- tests/integration/db-migration-v3.test.ts | 1 + tests/integration/dexie-migration.test.ts | 10 +- tests/integration/gateway-validation.test.ts | 2 +- .../integration/indexeddb-persistence.test.ts | 3 + tests/integration/simulation-engine.test.ts | 1 + tests/integration/sprint1-flow.test.ts | 1 + tests/integration/sprint1-report-roi.test.ts | 2 + tests/lib/export/csv-export-roi.test.ts | 4 +- tests/lib/export/csv-export.test.ts | 2 +- tests/lib/export/measure-sizes.test.ts | 3 +- tests/lib/export/pdf-export-roi.test.ts | 3 + tests/lib/export/pdf-export-sprint2.test.ts | 2 + tests/lib/export/pdf-export.test.ts | 1 + tests/lib/export/pdf-orientation.test.ts | 1 + .../lib/export/pdf-resources-section.test.ts | 41 ++ tests/lib/export/pdf-roi-page1.test.ts | 1 + tests/lib/export/pdf-roi-pages2-5.test.ts | 5 +- tests/lib/export/pdf-scenario-page1.test.ts | 1 + tests/persistence/migration-v4.test.ts | 220 +++++++ .../simulation-store-invalidation.test.ts | 5 +- 57 files changed, 4372 insertions(+), 82 deletions(-) create mode 100644 public/inquality-logo-color.png create mode 100644 public/inquality-logo-white.png create mode 100644 sprints/sprint-3/BRIEF.md create mode 100644 sprints/sprint-3/ETAPA_1_PROMPT.md create mode 100644 sprints/sprint-3/ETAPA_2_PROMPT.md create mode 100644 sprints/sprint-3/ETAPA_3_PROMPT.md create mode 100644 sprints/sprint-3/ETAPA_4_PROMPT.md create mode 100644 sprints/sprint-3/ETAPA_5_PROMPT.md create mode 100644 sprints/sprint-3/ETAPA_6_PROMPT.md create mode 100644 sprints/sprint-3/ETAPA_7_PROMPT.md create mode 100644 src/components/AppHeader.tsx create mode 100644 src/features/library/LibraryPage.tsx create mode 100644 src/features/settings/SettingsPage.tsx create mode 100644 src/store/library-store.ts create mode 100644 tests/domain/simulation-sprint3.test.ts create mode 100644 tests/persistence/migration-v4.test.ts diff --git a/public/favicon.svg b/public/favicon.svg index bbc5747..f35b425 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,10 +1,9 @@ - - - - - - - - - - + + + + \ No newline at end of file diff --git a/public/inquality-logo-color.png b/public/inquality-logo-color.png new file mode 100644 index 0000000000000000000000000000000000000000..a5ac695e4333fe8d70c47e5e21b6e68b9894f34a GIT binary patch literal 65309 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 z

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%0Dx(xg=3f5$sIT7t7v2w!p$V|t4DE|1 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 literal 0 HcmV?d00001 diff --git a/public/inquality-logo-white.png b/public/inquality-logo-white.png new file mode 100644 index 0000000000000000000000000000000000000000..a79c00d487b8fb5272dad101e33b454843cceb24 GIT binary patch literal 35242 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 literal 0 HcmV?d00001 diff --git a/sprints/sprint-3/BRIEF.md b/sprints/sprint-3/BRIEF.md new file mode 100644 index 0000000..7ba1168 --- /dev/null +++ b/sprints/sprint-3/BRIEF.md @@ -0,0 +1,440 @@ +# Sprint 3 — Brief + +> **Propósito:** dos ejes paralelos — (1) construir la Process Library: una biblioteca navegable y organizada de procesos con jerarquía configurable y tags; (2) completar el Modelo A: indicador de simulación desactualizada, zoom en canvas BPMN, y recursos en el escenario automatizado. +> +> **Usuario objetivo:** equipo de InQuality. Uso interno en talleres de análisis de procesos y pre-venta de automatización. +> +> **Sprint anterior:** Sprint 2 — modelo de recursos `utilizationMinutes + units` (Dexie v3), ActivityPanel "wow", reporte web con filas expandibles, edición inline proceso/cliente, tabla de recursos en PDF. 548 tests al cierre. Ver cierre en `methodology/CASE_STUDIES/inq-roi-sprint-2.md`. + +--- + +## 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. +- **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. +- **Control preventivo de scope:** incluir `git diff --name-only HEAD` en todo prompt. Archivo no autorizado en el diff → revertir y reportar. Ver `methodology/PATTERNS.md` C.7. +- **Restricciones contradictorias:** si un prompt tiene restricción `❌` sobre un archivo y el cambio solicitado genera conflicto inevitable, DETENER y reportar al director. No resolver unilateralmente. Ver `methodology/ANTI_PATTERNS.md` AP.9. + +--- + +## 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. + +### 1.3 Decisión estratégica: PostgreSQL diferido + +La persistencia en Dexie (IndexedDB) se mantiene hasta Sprint 5+. La decisión fue tomada conscientemente: el driver para una migración a PostgreSQL (multi-usuario, multi-tenant) no existe todavía en el uso interno de InQuality. El schema de Dexie v4 debe diseñarse con relaciones normalizadas y UUIDs para que la migración futura a SQL sea limpia. + +--- + +## 2. Estado real del producto (pre-Sprint 3) + +### 2.1 Lo que existe en la persistencia (Dexie v3) + +| Tabla | Estado | +|---|---| +| `processes` | ✅ `{ id, name, clientName, bpmnXml, currency, overheadPercentage, annualFrequency, analysisHorizonYears, automationInvestment, createdAt, updatedAt }` | +| `activities` | ✅ incluye `assignedResources: ActivityResourceAssignment[]`, `automatable`, `automatedCostFixed`, `automatedTimeMinutes` | +| `resources` | ✅ `{ id, processId, name, type, costPerHour }` | +| `gateways` | ✅ `{ id, processId, bpmnElementId, gatewayType, branches }` | +| `simulations` | ✅ `{ id, processId, executedAt, result, resultAutomated? }` | + +### 2.2 Lo que falta y se construye en Sprint 3 + +| Elemento | Estado actual | Sprint 3 | +|---|---|---| +| Agrupación de procesos | ❌ No existe | ✅ Tabla `groups` + `groupId` en `Process` | +| Tags en procesos | ❌ No existe | ✅ Campo `tags: string[]` en `Process` | +| Configuración de etiquetas | ❌ No existe | ✅ Tabla `settings` con `groupLabel` configurable | +| Vista de biblioteca | ❌ No existe | ✅ Home navegable con grupos y procesos | +| Búsqueda y filtros | ❌ No existe | ✅ Búsqueda por nombre + filtro por tags | +| Indicador stale | ❌ No existe | ✅ Badge cuando proceso tiene cambios sin simular | +| Zoom/fit canvas BPMN | ❌ No existe | ✅ Botón fit-to-viewport | +| Recursos en escenario automatizado | ❌ `automatedCostFixed` solamente | ✅ `automatedAssignedResources[]` + simulación | + +--- + +## 3. Decisiones arquitectónicas del sprint (validadas por el director) + +| Decisión | Resolución | +|---|---| +| Versión Dexie | Incrementar a v4. Migración preserva datos existentes. | +| Jerarquía de biblioteca | Dos niveles: Grupo (etiqueta configurable) + Proceso | +| Etiquetas configurables | Una sola etiqueta para el nivel de grupo (`groupLabel`). Default: `"Cliente"`. Configurable por workspace. | +| Scope de `settings` | Singleton en IndexedDB — un registro por instalación. Preparado para extensión en Sprint 5+ cuando llegue multi-tenant. | +| Indicador stale | Comparar `process.updatedAt` vs `latestSimulation.executedAt`. Requiere garantizar que guardar activity/resource/gateway actualiza `process.updatedAt`. Sin campo nuevo. | +| Recursos en automatizado | Nuevo campo `automatedAssignedResources: ActivityResourceAssignment[]` en `Activity`. Migración v4 popula con array vacío. | +| Costo automatizado total | `automatedCostTotal = automatedCostFixed + SUM(automatedResourceCosts)` — mismo patrón que AS-IS | +| UUIDs en nuevas tablas | `groups` y `settings` usan UUIDs para compatibilidad futura con PostgreSQL | +| PostgreSQL | Diferido a Sprint 5+. No afecta diseño de Sprint 3 salvo por las consideraciones de UUIDs y normalización. | + +### 3.1 Schema Dexie v4 — cambios + +```typescript +// NUEVA tabla — ProcessGroup +export interface ProcessGroup { + id: UUID // generado con crypto.randomUUID() + name: string + createdAt: number // timestamp Unix ms + updatedAt: number +} + +// NUEVA tabla — AppSettings (singleton) +export interface AppSettings { + id: 'singleton' // siempre un solo registro + groupLabel: string // default: 'Cliente' +} + +// Process — campos NUEVOS (migración v4 popula con defaults) +export interface Process { + // ... campos existentes sin cambio ... + groupId: UUID | null // 🆕 null = "Sin clasificar" + tags: string[] // 🆕 [] +} + +// Activity — campo NUEVO (migración v4 popula con []) +export interface Activity { + // ... campos existentes sin cambio ... + automatedAssignedResources: ActivityResourceAssignment[] // 🆕 [] +} +``` + +### 3.2 Migración v3 → v4 + +```typescript +this.version(4) + .stores({ + processes: 'id, name, updatedAt, groupId', // groupId indexado para queries por grupo + activities: 'id, processId, bpmnElementId', + gateways: 'id, processId, bpmnElementId', + resources: 'id, processId, name', + simulations:'id, processId, executedAt', + groups: 'id, name, updatedAt', // nueva tabla + settings: 'id', // nueva tabla (singleton) + }) + .upgrade(async (tx) => { + // Processes: agregar groupId y tags + await tx.table('processes').toCollection().modify((proc) => { + if (proc.groupId === undefined) proc.groupId = null + if (!Array.isArray(proc.tags)) proc.tags = [] + }) + // Activities: agregar automatedAssignedResources + await tx.table('activities').toCollection().modify((act) => { + if (!Array.isArray(act.automatedAssignedResources)) { + act.automatedAssignedResources = [] + } + }) + // Settings: crear singleton con defaults + await tx.table('settings').put({ id: 'singleton', groupLabel: 'Cliente' }) + }) +``` + +### 3.3 Fórmula de costo automatizado actualizada + +``` +costoRecursoAutomatizado = resource.costPerHour × assignment.utilizationMinutes / 60 × assignment.units +costoAutomatizadoActividad = automatedCostFixed + SUM(costoRecursoAutomatizado por cada automatedAssignment) +``` + +La función `simulate(input, scenario)` ya existe. Al pasar `scenario = 'automated'`, para actividades `automatable = true`, usar `automatedAssignedResources` en lugar de `assignedResources`. + +--- + +## 4. Features del sprint + +--- + +### Feature A — Dexie v4: fundación del modelo de biblioteca (Etapa 1) + +**Peso estimado:** 10% del sprint. + +Data layer puro. Sin cambios de UI. Objetivo: que el schema esté correcto y los datos existentes preservados antes de construir cualquier UI encima. + +- Nuevas interfaces `ProcessGroup` y `AppSettings` en `types.ts` +- Nuevos campos `groupId` y `tags` en `Process`, `automatedAssignedResources` en `Activity` +- `db.ts`: versión 4 con las dos nuevas tablas y función de upgrade +- Tests de migración: proceso existente conserva todos los campos, `groupId = null`, `tags = []`, `automatedAssignedResources = []` +- Test de idempotencia: ejecutar upgrade dos veces no corrompe datos + +**Criterio de aceptación:** +- [ ] Migración v3→v4 sin pérdida de datos (test automatizado) +- [ ] `ProcessGroup` y `AppSettings` disponibles en TypeScript sin errores +- [ ] `settings` singleton creado con `groupLabel: 'Cliente'` por defecto +- [ ] Build limpio sin errores TypeScript +- [ ] `git diff --name-only HEAD` muestra solo los archivos autorizados + +**Archivos autorizados:** `src/domain/types.ts`, `src/persistence/db.ts`, tests de migración. + +--- + +### Feature B — Vista de biblioteca + gestión de grupos (Etapa 2) + +**Peso estimado:** 20% del sprint. + +La biblioteca pasa a ser la home de la aplicación. El flujo anterior (importar BPMN directamente al llegar) se mantiene como acción secundaria desde la biblioteca. + +**Versión mínima:** +- Nueva ruta `/` o equivalente que muestra la biblioteca +- Lista de grupos con contador de procesos +- Sección "Sin clasificar" para `groupId = null` +- CRUD de grupos: crear, renombrar, eliminar + - Eliminar grupo: procesos del grupo pasan a "Sin clasificar" (no se borran) +- Vista de procesos dentro de un grupo: nombre, cliente, fecha de última modificación +- Botón "Nuevo proceso" (importar BPMN) disponible globalmente y dentro de cada grupo +- Al crear/importar un proceso desde dentro de un grupo, asigna `groupId` automáticamente + +**Versión con wow (recomendada):** +Todo lo anterior más: +- Empty state diseñado para la primera vez (sin grupos ni procesos): ilustración o mensaje de bienvenida con CTA para crear el primer grupo o importar un proceso +- Empty state para grupo vacío: "Este grupo no tiene procesos. Importá un BPMN para empezar." +- Hover sobre proceso: preview con fecha de última simulación y costo total (si existe) +- Breadcrumb al entrar a un grupo: `groupLabel / nombre del grupo` + +**Criterio de aceptación:** +- [ ] Navegación entre biblioteca y grupos funciona sin recargar +- [ ] CRUD de grupos opera correctamente (incluyendo caso de borrado con procesos) +- [ ] Procesos existentes sin grupo aparecen en "Sin clasificar" +- [ ] Asignación automática de grupo al crear proceso desde dentro de uno +- [ ] Validación visual del director aprobada + +--- + +### Feature C — Tags + búsqueda + configuración de etiquetas (Etapa 3) + +**Peso estimado:** 15% del sprint. + +**C.1 — Tags en procesos** + +Chip input en el workspace del proceso (cerca del nombre y cliente) para agregar y quitar tags. Los tags son strings libres. Autocompletado con tags ya existentes en la base (evita fragmentación "BPMN" / "bpmn" / "Bpmn"). + +**C.2 — Búsqueda y filtros en biblioteca** + +- Búsqueda en tiempo real por nombre de proceso (filtra mientras se escribe) +- Filtro por tags: chips seleccionables que muestran solo procesos con esos tags +- Los filtros activos se muestran visualmente como chips removibles +- La búsqueda opera tanto dentro de un grupo como en "todos los procesos" + +**C.3 — Configuración de etiquetas** + +Pantalla de configuración (ruta `/settings` o modal) donde el usuario define: +- `groupLabel`: cómo se llama el nivel de agrupación (default: "Cliente"). Este label aparece en toda la UI donde antes decía "Grupo". +- La configuración persiste en la tabla `settings` de Dexie. + +**Versión con wow (recomendada):** +Todo lo anterior más: +- En la vista de biblioteca sin filtro activo: mostrar un tag cloud visual con los tags más usados — click en un tag activa el filtro +- El label configurado se refleja en tiempo real en la UI sin recargar (reactive desde el settings store) + +**Criterio de aceptación:** +- [ ] Tags se guardan y persisten en el proceso +- [ ] Autocompletado funciona con tags existentes (case-insensitive) +- [ ] Búsqueda filtra procesos en tiempo real +- [ ] Filtro por tags funciona en modo AND (proceso debe tener todos los tags seleccionados) +- [ ] `groupLabel` configurable y reflejado en la UI +- [ ] Validación visual del director aprobada + +--- + +### Feature D — Indicador de simulación desactualizada + zoom BPMN (Etapa 4) + +**Peso estimado:** 10% del sprint. + +**D.1 — Indicador stale** + +Cuando el usuario guarda cambios en actividades, recursos o gateways de un proceso sin volver a simular, el workspace muestra un indicador persistente. + +Implementación: garantizar que `db.processes.update(id, { updatedAt: Date.now() })` se ejecuta siempre que se guarden activities, resources o gateways del proceso. El indicador es `process.updatedAt > (latestSimulation?.executedAt ?? 0)`. + +**Versión mínima:** badge "Simular para actualizar" en el botón Simular (o cerca). Desaparece al ejecutar. + +**Versión con wow (recomendada):** +Todo lo anterior más: +- El indicador menciona cuántas actividades tienen cambios sin simular (requiere comparar `activity.updatedAt` — agregar este campo solo si el esfuerzo es < 40% del mínimo; si no, omitir) +- El indicador también aparece en el reporte web como banner sutil: "Los resultados pueden estar desactualizados — hay cambios sin simular" con botón "Simular ahora" + +**D.2 — Zoom/fit canvas BPMN** + +Botón "Encuadrar diagrama" (ícono fit-to-screen o similar) en la toolbar del BpmnCanvas. Al hacer click: `bpmnViewer.get('canvas').zoom('fit-viewport')`. Detectado en validación Sprint 2 como problema real en talleres. + +**Criterio de aceptación:** +- [ ] Indicador aparece al guardar actividad sin re-simular +- [ ] Indicador desaparece al ejecutar simulación +- [ ] Botón fit-to-viewport funciona en BpmnCanvas +- [ ] Validación visual del director aprobada + +--- + +### Feature E — Recursos en escenario automatizado (Etapa 5) + +**Peso estimado:** 20% del sprint. + +Esta es la feature más compleja de Modelo A. El campo `automatedAssignedResources` ya existe en el schema (creado en Etapa 1). Etapa 5 construye la UI y la lógica de simulación. + +**E.1 — UI en ActivityPanel** + +En la sección "Automatización" del ActivityPanel (visible cuando `automatable = true`), agregar una subsección "Recursos automatizados" paralela a la del escenario actual: +- Mismo componente de asignación de recursos (resourceId, utilizationMinutes, units) +- Recursos disponibles: los mismos del catálogo del proceso (tabla `resources`) +- Label diferenciador: "Recursos en AS-IS" / "Recursos en TO-BE" (o "Actual" / "Automatizado") + +**E.2 — Motor de simulación** + +Al simular con `scenario = 'automated'`, para actividades `automatable = true`: +- Usar `automatedAssignedResources` en lugar de `assignedResources` +- Costo total = `automatedCostFixed + SUM(costoRecursoAutomatizado)` +- El `resourceCostBreakdown` en `ActivitySimResult` refleja los recursos automatizados + +**E.3 — Reporte web** + +En las filas expandibles de `ActivitiesTable` para actividades automatizables: +- **Versión mínima:** mostrar desglose de recursos del escenario que corresponde (actual en columna actual, automatizado en columna automatizado) +- **Versión con wow:** comparación lado a lado — fila expandida muestra "Actual: X recursos" / "Automatizado: Y recursos" con sus costos individuales + +**Criterio de aceptación:** +- [ ] Se pueden asignar recursos al escenario automatizado desde el ActivityPanel +- [ ] La simulación usa esos recursos al calcular el costo automatizado +- [ ] El reporte muestra el desglose de recursos del escenario automatizado +- [ ] Escenario sin recursos automatizados: comportamiento idéntico al anterior (solo `automatedCostFixed`) +- [ ] Tests unitarios: costo automatizado con recursos, sin recursos, mixto (fijo + recursos) +- [ ] Validación visual del director aprobada + +--- + +### Feature F — PDF actualizado + tests del sprint (Etapa 6) + +**Peso estimado:** 15% del sprint. + +**F.1 — PDF con recursos automatizados** + +Si el proceso tiene actividades con `automatedAssignedResources`, la tabla de recursos en página 4 del PDF (introducida en Sprint 2) muestra ambos escenarios: +- Sección "Recursos AS-IS" (existente) +- Sección "Recursos TO-BE" (nueva, condicional — solo si hay recursos automatizados) + +Si ninguna actividad tiene recursos automatizados, la tabla PDF no cambia respecto a Sprint 2. + +**F.2 — Tests del sprint** + +- Tests unitarios de migración Dexie v4 (cobertura de los nuevos campos) +- Tests unitarios del motor de simulación con `automatedAssignedResources` +- Test E2E del flujo completo de la biblioteca: crear grupo → importar proceso → asignar a grupo → agregar tags → buscar → filtrar +- Tests de la configuración de `groupLabel` (cambio se refleja en UI) + +**Criterio de aceptación:** +- [ ] PDF con sección TO-BE aparece cuando hay recursos automatizados +- [ ] PDF sin recursos automatizados: sin cambio respecto a Sprint 2 +- [ ] Todos los tests nuevos del sprint verdes +- [ ] Validación visual del director aprobada (abrir el PDF generado) + +--- + +### Feature G — Polish + validación final (Etapa 7) + +**Peso estimado:** 10% del sprint. + +Refinamientos post-validación visual del director de las etapas anteriores. Edge cases de la biblioteca (nombre de grupo muy largo, proceso sin nombre, muchos tags, tag muy largo). Ajustes de animación o spacing que emerjan en uso real. Cualquier inconsistencia de estilo entre la biblioteca nueva y el resto de la app. + +Esta etapa no tiene scope predefinido — se construye a partir del feedback acumulado de la validación visual de Etapas 2-6. + +**Criterio de aceptación:** +- [ ] Director da OK formal al producto visual completo del sprint +- [ ] Sin regresiones en features existentes (tests E2E del sprint anterior siguen verdes) + +--- + +## 5. Estructura de etapas + +| Etapa | Contenido | Peso | +|---|---|---| +| 1 | Dexie v4: nuevas tablas, campos, migración, tests | 10% | +| 2 | Vista de biblioteca + gestión de grupos | 20% | +| 3 | Tags + búsqueda + configuración de etiquetas | 15% | +| 4 | Indicador simulación desactualizada + zoom BPMN | 10% | +| 5 | Recursos en escenario automatizado (UI + simulación + reporte) | 20% | +| 6 | PDF recursos automatizados + tests del sprint | 15% | +| 7 | Polish + validación final | 10% | + +**Proporción por componente:** web app ~75% (etapas 2, 3, 4, 5) · PDF + tests ~25% (etapas 1, 6, 7). + +**Regla operativa:** ninguna etapa arranca sin su `ETAPA_X_PROMPT.md` aprobado por el director. + +--- + +## 6. Lo que explícitamente NO entra en Sprint 3 + +- Catálogo global de recursos compartido entre procesos → evaluar Sprint 4 con feedback de uso real +- Modelo B (AS-IS / TO-BE con dos BPMNs) → decisión diferida a Sprint 4+ +- Múltiples simulaciones / historial de escenarios → BACKLOG +- Migración a PostgreSQL → Sprint 5+ (decisión tomada explícitamente) +- Login, multi-tenant, colaboración → Fase 2 +- Gateway OR inclusivo nativo → BACKLOG +- Distribuciones de probabilidad → Fase 2 +- Export Excel → BACKLOG + +--- + +## 7. Deuda técnica que se cierra en este sprint + +| Ítem en TECH_DEBT.md | Etapa | +|---|---| +| Toggle automatable + Guardar requerido para badge (INVESTIGATING) | 4 — resolver como parte del indicador stale | +| Indicador visual actividades automatizables en reporte (BACKLOG) | 5 — cubierto por desglose en filas expandibles | +| Controles de zoom en canvas BPMN (BACKLOG) | 4 | + +--- + +## 8. Definition of Done — Sprint 3 + +- [ ] Features A–G: todos los criterios de aceptación cumplidos +- [ ] Migración Dexie v3→v4: test automatizado confirma preservación de datos y correctos defaults +- [ ] Tests unitarios: motor de simulación con `automatedAssignedResources` (sin recursos, solo fijo, mixto, solo recursos) +- [ ] Test E2E: flujo completo de biblioteca (crear grupo, asignar proceso, tags, búsqueda, filtro) +- [ ] Validación visual: biblioteca navegable, indicador stale, comparación recursos en reporte, PDF TO-BE +- [ ] TECH_DEBT.md actualizado: ítems cerrados documentados +- [ ] OBSERVATIONS.md limpio al cerrar +- [ ] STRATEGIC_DIRECTION.md actualizado con reflexiones post-sprint +- [ ] Ratio web/PDF respetado (~75% web, ~25% PDF + infra) + +--- + +## 9. Riesgos + +| Riesgo | Probabilidad | Mitigación | +|---|---|---| +| La Etapa 5 (recursos automatizados) tiene dependencias de tipo transitivas con archivos de etapas anteriores | Media | Incluir instrucción AP.9 explícita: si hay conflicto en archivos restringidos, DETENER y reportar. | +| El indicador stale requiere rastrear `updatedAt` a nivel de actividad (actualmente no existe) | Media | Especificado en el BRIEF: solo comparar `process.updatedAt` vs `simulation.executedAt`. Si se quiere granularidad por actividad, evaluar esfuerzo antes de comprometerse. | +| La biblioteca reemplaza la home y puede romper el flujo de importación existente | Media | Diseñar la biblioteca para que el flujo de importación sea acción prominente, no secundaria. Testear el E2E de importación como parte de Etapa 2. | +| `settings` singleton asume una instalación = un usuario. Si en el futuro hay multi-usuario en el mismo browser, colisiona. | Baja | Aceptado conscientemente. El diseño con `id: 'singleton'` es limpio y fácil de migrar cuando llegue multi-tenant. | + +--- + +## 10. Referencias + +- `CLAUDE.md` (simulador-web) — reglas de implementación +- `STRATEGIC_DIRECTION.md` — decisiones estratégicas post-Sprint 2 +- `BACKLOG.md` — ítems "Indicador simulación desactualizada", "Zoom BPMN", "Tags" +- `TECH_DEBT.md` — ítems toggle automatable, indicador visual, zoom +- `methodology/PATTERNS.md` — C.6, C.7 +- `methodology/ANTI_PATTERNS.md` — AP.8, AP.9 +- `sprints/sprint-2/BRIEF.md` — contexto del modelo de recursos y Dexie v3 +- `methodology/CASE_STUDIES/inq-roi-sprint-2.md` — lecciones del sprint anterior +- `assets/InQ manual de marca- 3.0.pdf` — manual de marca oficial diff --git a/sprints/sprint-3/ETAPA_1_PROMPT.md b/sprints/sprint-3/ETAPA_1_PROMPT.md new file mode 100644 index 0000000..a159323 --- /dev/null +++ b/sprints/sprint-3/ETAPA_1_PROMPT.md @@ -0,0 +1,215 @@ +# Sprint 3 — Etapa 1: Dexie v4 — fundación del modelo de biblioteca + +## Contexto + +Sos Claude Code trabajando en **InQ ROI**, una herramienta web para análisis de costos de procesos BPMN y cálculo de ROI de automatización. El proyecto está en `simulador-web/`. Las reglas de implementación están en `simulador-web/CLAUDE.md`. El brief del sprint está en `sprints/sprint-3/BRIEF.md`. + +**Estado al iniciar esta etapa:** +- Dexie v3 en producción. Schema actual: `processes`, `activities`, `gateways`, `resources`, `simulations`. +- 548 tests verdes (Vitest + Playwright). +- Deploy activo: https://process-cost-platform.pages.dev + +**Esta etapa es data layer puro. Sin cambios de UI. Sin cambios de lógica de simulación.** + +--- + +## Objetivo + +Migrar la base de datos local a Dexie v4 agregando las estructuras necesarias para la Process Library del Sprint 3: + +1. Nuevas interfaces TypeScript: `ProcessGroup` y `AppSettings` +2. Nuevos campos en `Process`: `groupId` y `tags` +3. Nuevo campo en `Activity`: `automatedAssignedResources` +4. Versión 4 de Dexie con función de upgrade que preserva todos los datos existentes +5. Tests de migración que verifican preservación de datos y correctos defaults + +--- + +## Cambios autorizados + +### 1. `src/domain/types.ts` + +Agregar al final de la sección de entidades del dominio (antes de los tipos del motor de simulación): + +```typescript +// ─── Process Library ───────────────────────────────────────────────────────── + +export interface ProcessGroup { + id: UUID // generado con crypto.randomUUID() + name: string + createdAt: number // timestamp Unix ms + updatedAt: number // timestamp Unix ms +} + +export interface AppSettings { + id: 'singleton' // siempre un único registro por instalación + groupLabel: string // etiqueta configurable para el nivel de agrupación. Default: 'Cliente' +} +``` + +Actualizar `Process` agregando los dos campos nuevos **al final de la interfaz** (para no romper destructuring existente): + +```typescript +export interface Process { + // ... campos existentes sin tocar ... + groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar") + tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda +} +``` + +Actualizar `Activity` agregando el campo nuevo **al final de la interfaz**: + +```typescript +export interface Activity { + // ... campos existentes sin tocar ... + automatedAssignedResources: ActivityResourceAssignment[] // 🆕 Sprint 3 — recursos del escenario automatizado +} +``` + +### 2. `src/persistence/db.ts` + +Agregar imports de los nuevos tipos: + +```typescript +import type { Process, Activity, GatewayConfig, Resource, Simulation, ProcessGroup, AppSettings } from '@/domain/types' +``` + +Agregar las dos tablas nuevas a la clase: + +```typescript +export class ProcessCostDb extends Dexie { + processes!: Table + activities!: Table + gateways!: Table + resources!: Table + simulations!: Table + groups!: Table // 🆕 Sprint 3 + settings!: Table // 🆕 Sprint 3 + // ... +} +``` + +Agregar versión 4 **después** de la versión 3 existente: + +```typescript +// Sprint 3: agrega tabla de grupos (Process Library), tabla de settings, +// campos groupId/tags en Process, y automatedAssignedResources en Activity. +this.version(4) + .stores({ + processes: 'id, name, updatedAt, groupId', // groupId indexado para queries por grupo + activities: 'id, processId, bpmnElementId', + gateways: 'id, processId, bpmnElementId', + resources: 'id, processId, name', + simulations: 'id, processId, executedAt', + groups: 'id, name, updatedAt', // nueva tabla + settings: 'id', // nueva tabla (singleton) + }) + .upgrade(async (tx) => { + // Process: agregar groupId (null) y tags ([]) + await tx.table('processes').toCollection().modify((proc) => { + if (proc.groupId === undefined) proc.groupId = null + if (!Array.isArray(proc.tags)) proc.tags = [] + }) + // Activity: agregar automatedAssignedResources ([]) + await tx.table('activities').toCollection().modify((act) => { + if (!Array.isArray(act.automatedAssignedResources)) { + act.automatedAssignedResources = [] + } + }) + // Settings: crear singleton con defaults si no existe + const existing = await tx.table('settings').get('singleton') + if (!existing) { + await tx.table('settings').put({ id: 'singleton', groupLabel: 'Cliente' }) + } + }) +``` + +### 3. Tests de migración + +Crear `tests/persistence/migration-v4.test.ts` con los siguientes casos: + +**Test 1 — Proceso existente conserva todos sus campos** +Crear un proceso con el schema v3 (sin `groupId` ni `tags`), ejecutar la migración, verificar que todos los campos originales siguen intactos y que `groupId === null` y `tags` es un array vacío. + +**Test 2 — Activity existente conserva todos sus campos y recibe array vacío** +Crear una activity con `assignedResources` poblado (al menos un assignment), ejecutar migración, verificar que `assignedResources` no se alteró y que `automatedAssignedResources` es `[]`. + +**Test 3 — Settings singleton creado con defaults** +Después de la migración, leer `settings.get('singleton')`, verificar que existe con `groupLabel === 'Cliente'`. + +**Test 4 — Idempotencia: upgrade no corrompe datos ya migrados** +Crear un proceso ya en formato v4 (con `groupId: null`, `tags: []`), ejecutar upgrade, verificar que los valores no cambian. + +**Test 5 — groupId puede ser un UUID válido** +Crear un grupo, asignar su `id` como `groupId` de un proceso, verificar que el campo persiste correctamente y que se puede hacer query `processes.where('groupId').equals(groupId)`. + +--- + +## ❌ Lo que NO entra en esta etapa + +- Cambios en ningún componente de UI (`features/`, `components/`) +- Cambios en el motor de simulación (`domain/simulation.ts`) +- Cambios en `domain/schemas.ts` (los schemas Zod se actualizan en la etapa correspondiente) +- Cambios en lógica de exportación de PDF o CSV +- Cualquier otro archivo fuera de `src/domain/types.ts`, `src/persistence/db.ts`, y los tests nuevos + +Si algún cambio en `types.ts` genera errores TypeScript en otros archivos por los nuevos campos opcionales/requeridos: **DETENER y reportar el conflicto al director antes de modificar esos archivos**. No resolver unilateralmente. Ver `methodology/ANTI_PATTERNS.md` AP.9. + +--- + +## Verificación antes de entregar + +```bash +# 1. Build limpio +npm run build + +# 2. Tests unitarios +npm run test + +# 3. Control de scope — solo deben aparecer archivos autorizados +git diff --name-only HEAD +``` + +El `git diff` debe mostrar únicamente: +- `src/domain/types.ts` +- `src/persistence/db.ts` +- `tests/persistence/migration-v4.test.ts` (nuevo) + +Si aparece cualquier otro archivo: **revertirlo y documentarlo como conflicto de scope**. + +--- + +## Criterios de aceptación + +- [ ] `ProcessGroup` y `AppSettings` exportados desde `src/domain/types.ts` +- [ ] `Process.groupId` y `Process.tags` presentes en la interfaz +- [ ] `Activity.automatedAssignedResources` presente en la interfaz +- [ ] `db.ts` versión 4 con tablas `groups` y `settings` registradas +- [ ] Migración v3→v4: procesos existentes reciben `groupId: null` y `tags: []` +- [ ] Migración v3→v4: activities existentes reciben `automatedAssignedResources: []` +- [ ] Singleton de settings creado con `groupLabel: 'Cliente'` +- [ ] Los 5 tests de migración verdes +- [ ] Todos los tests previos del proyecto siguen verdes (548+) +- [ ] Build limpio sin errores TypeScript +- [ ] `git diff --name-only HEAD` muestra solo los 3 archivos autorizados + +--- + +## Reporte esperado al terminar + +Incluir en el reporte: +1. Resultado de `git diff --name-only HEAD` +2. Cantidad de tests nuevos y resultado +3. Resultado del build (`npm run build`) +4. Confirmación de que ningún archivo fuera del scope fue modificado +5. Si hubo alguna decisión de iniciativa (Nivel 1/2/3) tomada durante la etapa, declararla explícitamente + +--- + +## Referencias + +- `sprints/sprint-3/BRIEF.md` — scope completo del sprint, sección 3 (decisiones arquitectónicas) +- `src/persistence/db.ts` — versiones anteriores como referencia de estilo +- `src/domain/types.ts` — estado actual del dominio +- `methodology/ANTI_PATTERNS.md` — AP.9 (restricciones contradictorias) +- `methodology/PATTERNS.md` — C.7 (git diff como control preventivo) diff --git a/sprints/sprint-3/ETAPA_2_PROMPT.md b/sprints/sprint-3/ETAPA_2_PROMPT.md new file mode 100644 index 0000000..ae2a815 --- /dev/null +++ b/sprints/sprint-3/ETAPA_2_PROMPT.md @@ -0,0 +1,338 @@ +# Sprint 3 — Etapa 2: Vista de biblioteca + gestión de grupos + +## Contexto + +Sos Claude Code trabajando en **InQ ROI**. Las reglas de implementación están en `simulador-web/CLAUDE.md`. El brief del sprint está en `sprints/sprint-3/BRIEF.md`. + +**Estado al iniciar esta etapa:** +- Etapa 1 completada: Dexie v4 en producción. Tablas `groups` y `settings` disponibles. `Process.groupId?` y `Process.tags?` existen como campos opcionales. `Activity.automatedAssignedResources?` existe como campo opcional. +- La app tiene: `ImportPage` en `/` (home actual), `WorkspacePage` en `/workspace/$processId`, `ReportPage` en `/report/$processId`. +- Stack de routing: TanStack Router. Store: Zustand (`process-store.ts`, `simulation-store.ts`). Repos: `repositories.ts`. + +**Esta etapa construye la Process Library y la convierte en la home de la app.** + +--- + +## Objetivo + +1. Crear `LibraryPage`: la nueva home de la aplicación — lista de grupos, navegación a procesos dentro de un grupo, búsqueda, tags, CRUD de grupos. +2. Crear `library-store.ts`: Zustand store para operaciones de biblioteca (grupos + lista de procesos). +3. Extender `repositories.ts` con `groupRepo` y `settingsRepo`. +4. Actualizar `router.tsx`: LibraryPage en `/`, ImportPage en `/import`. +5. Actualizar `ImportPage` y todos los archivos de tests que construyen `Process` o `Activity` literalmente para agregar los nuevos campos requeridos. +6. Promover `Process.groupId` y `Process.tags` a campos **requeridos** en `types.ts` (quitar `?`). `Activity.automatedAssignedResources` permanece opcional hasta Etapa 5. + +--- + +## Contrato visual — respetar sin interpretar + +El diseño fue aprobado por el director. Replicarlo fielmente. No agregar features visuales que no estén acá. + +### Vista home (lista de grupos) + +**Header:** +- Título "Biblioteca de procesos" (18px, weight 500) con subtítulo "N procesos · N clientes" (12px, color tertiary). +- Botón naranja `#F59845` "Importar BPMN" (ícono upload) alineado a la derecha. Al hacer click navega a `/import`. +- Sin botón de configuración en el header. + +**Barra de búsqueda:** +- Input full-width con ícono de lupa a la izquierda. Placeholder "Buscar proceso por nombre...". Filtra en tiempo real sobre todos los procesos de todos los grupos (búsqueda global). +- Cuando hay búsqueda activa, mostrar resultados flat (sin agrupar por grupo) con chip que indica a qué grupo pertenece cada proceso. + +**Fila de tags:** +- Label "Tags:" seguido de chips seleccionables (border redondeado, fondo blanco, borde tertiary). +- Estado activo: fondo `#fff3e6`, borde `#F59845`, texto `#7a3e00`. +- Múltiples tags activos filtran en modo AND (proceso debe tener TODOS los tags seleccionados). +- Los tags mostrados son los que existen en al menos un proceso. Si no hay tags: no mostrar la fila. + +**Sección "Clientes" (o el `groupLabel` configurado):** +- Label de sección en uppercase, 12px, color secondary, con contador de grupos como badge gris pequeño. +- Grid de group cards: `repeat(auto-fill, minmax(160px, 1fr))`, gap 10px. + +**Group card (existente):** +- Fondo `background-primary`, borde 0.5px tertiary, border-radius-lg, borde izquierdo 3px `#F59845`. +- Número de procesos: 24px, weight 500, color `#F59845`, arriba. +- Nombre del grupo: 13px, weight 500, debajo del número, truncado con ellipsis. +- Fecha: "Actualizado hace X días" (11px, color tertiary), calculada desde el `updatedAt` más reciente entre los procesos del grupo. +- Al hacer click: navega a la vista de grupo (estado interno, sin cambio de URL). +- Al hover: borde cambia a `border-secondary`. + +**Group card "Nuevo cliente" (estado normal):** +- Mismo tamaño que las otras cards. Borde dashed 0.5px tertiary. Fondo `background-primary`. +- Contenido centrado: ícono `ti-plus` + texto "Nuevo cliente" (o el `groupLabel` configurado), color tertiary. +- Al hover: borde `#F59845`, texto `#F59845`. +- Al hacer click: **transforma inline en modo edición** (sin abrir modal). + +**Group card "Nuevo cliente" (estado edición):** +- La misma card cambia: borde 1.5px `#F59845`, muestra input de texto + botones "Crear" (naranja) y "Cancelar" (ghost). +- Input con placeholder "Nombre del cliente...". +- Enter confirma. Escape cancela. Nombre vacío → cancelar sin crear. +- Al confirmar: la card de input se reemplaza por la nueva group card en la grilla, y aparece una nueva card "Nuevo cliente" al final. + +**Sección "Sin clasificar":** +- Fila full-width, borde dashed, separada de la grilla de grupos. +- Ícono `ti-folder-off`, texto "Procesos sin cliente asignado" (13px, weight 500, color secondary), contador (11px, color tertiary). +- Solo visible si hay al menos un proceso con `groupId === null`. +- Al hacer click: navega a vista de grupo con `groupId === null`. + +### Vista de grupo (detalle) + +**Header:** +- Breadcrumb: link "← Biblioteca" (color `#F59845`, cursor pointer) + separador "/" + nombre del grupo (color primary, weight 500). +- Subtítulo: "N proceso(s)" (12px, color tertiary). +- Botón naranja "Importar BPMN" a la derecha. Al hacer click: navega a `/import?groupId=XXXX` para que el proceso importado quede asignado a este grupo automáticamente. +- Al hacer click en "← Biblioteca": vuelve a la vista home. + +**Barra de búsqueda:** +- Input con placeholder "Buscar en este cliente...". Filtra los procesos del grupo en tiempo real. + +**Lista de process cards:** +- Una card por proceso, apiladas verticalmente. + +**Process card:** +- Ícono `ti-git-branch` sobre fondo `#fff3e6` (36×36px, border-radius-md), a la izquierda. +- Columna central: nombre del proceso (13px weight 500, truncado), debajo "Modificado hace X días" (ícono `ti-clock` + texto, 11px, color tertiary), debajo chips de tags (11px, bg secondary, borde tertiary, border-radius 10px). +- Columna derecha (min-width 90px, text-align right): badge de KPI + label. + +**KPI badge — tres estados:** + +1. **Con escenario automatizado simulado** (hay `resultAutomated` en la última simulación): badge verde — fondo `#f0fdf4`, borde `#86efac`, texto `#15803d`, ícono `ti-trending-up`, valor "USD X.XXX". Label debajo: "Ahorro anual proyectado" (10px, color tertiary). + +2. **Simulado solo actual** (hay `result` pero no `resultAutomated`): badge neutro — fondo `background-secondary`, borde tertiary, texto secondary, ícono `ti-chart-bar`, valor "USD X.XXX". Label debajo: "Costo anual actual" (10px, color tertiary). El costo anual = `result.totalCost × annualFrequency` del proceso. + +3. **Sin simular**: texto "Sin simular" (11px, italic, color tertiary). Sin badge. + +### Empty states + +**Home sin grupos ni procesos (primera vez):** +- Ícono grande `ti-building` o similar centrado, texto "Tu biblioteca está vacía" (16px), subtítulo "Importá tu primer BPMN para empezar" (13px, color secondary), botón naranja "Importar BPMN". + +**Grupo sin procesos:** +- Ícono `ti-file-off`, texto "No hay procesos en este cliente.", subtítulo "Importá un BPMN para comenzar." (13px, color secondary). + +--- + +## Cambios autorizados y arquitectura + +### `src/domain/types.ts` +- Quitar `?` de `groupId` y `tags` en `Process` — campos requeridos. +- `automatedAssignedResources` permanece opcional (`?`) en `Activity` — se vuelve requerido en Etapa 5. + +### `src/persistence/repositories.ts` +Agregar al final, siguiendo el patrón existente: + +```typescript +// ─── ProcessGroup ───────────────────────────────────────────────────────────── + +/** @internal Solo llamar desde src/store/library-store.ts */ +export const groupRepo = { + async save(group: ProcessGroup): Promise { + await db.groups.put(group) + }, + async getAll(): Promise { + return db.groups.orderBy('name').toArray() + }, + async getById(id: string): Promise { + return db.groups.get(id) + }, + async delete(id: string): Promise { + // Procesos del grupo pasan a "Sin clasificar" (groupId = null) + await db.transaction('rw', [db.groups, db.processes], async () => { + await db.processes.where('groupId').equals(id).modify({ groupId: null }) + await db.groups.delete(id) + }) + }, +} + +// ─── AppSettings ────────────────────────────────────────────────────────────── + +/** @internal Solo llamar desde src/store/library-store.ts */ +export const settingsRepo = { + async get(): Promise { + const s = await db.settings.get('singleton') + return s ?? { id: 'singleton', groupLabel: 'Cliente' } + }, + async update(updates: Partial>): Promise { + await db.settings.update('singleton', updates) + }, +} +``` + +Actualizar el import en la cabecera para incluir `ProcessGroup` y `AppSettings`. + +### `src/store/library-store.ts` (NUEVO) + +Zustand store con la siguiente interfaz: + +```typescript +interface LibraryState { + groups: ProcessGroup[] + processes: Process[] // todos los procesos, para búsqueda global + settings: AppSettings + isLoading: boolean + + // Inicialización + load: () => Promise + + // Grupos + createGroup: (name: string) => Promise + deleteGroup: (id: string) => Promise + + // Procesos + assignProcessToGroup: (processId: string, groupId: string | null) => Promise + + // Computed helpers (derivados del estado, no acciones async) + getProcessesByGroup: (groupId: string | null) => Process[] + getLatestSimulation: (processId: string) => Promise +} +``` + +`load()` carga grupos, todos los procesos, y settings en paralelo. +`getProcessesByGroup(null)` retorna los procesos con `groupId === null` ("Sin clasificar"). +`getLatestSimulation` usa `simulationRepo.getLatestByProcess` — agregar `simulationRepo` al import del store. + +Respetar la regla arquitectónica: el store importa desde `repositories`, no directamente desde `db`. + +### `src/features/library/` (NUEVO directorio) + +Crear mínimamente: +- `LibraryPage.tsx` — componente principal, gestiona el estado `view: 'home' | 'group'` y `activeGroupId: string | null` con React state. +- Sub-componentes inline o en archivos separados según criterio del agente (Nivel 1 — decisión libre). + +### `src/router.tsx` +```typescript +// Antes: +// indexRoute → path: '/', component: ImportPage + +// Después: +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: LibraryPage, // nuevo home +}) + +const importRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/import', + component: ImportPage, // nueva ruta +}) + +// Agregar importRoute al routeTree +``` + +### `src/features/import/ImportPage.tsx` + +Dos cambios: +1. Agregar `groupId: null, tags: []` al objeto `Process` que se construye al importar. +2. Leer el search param `groupId` de la URL: si está presente, usarlo en lugar de `null`. Usar `useSearch` de TanStack Router. + +```typescript +// Leer groupId del search param (puede no estar presente) +const search = useSearch({ from: '/import' }) +const targetGroupId = (search as { groupId?: string }).groupId ?? null + +// Al construir el Process: +const process: Process = { + // ... campos existentes ... + groupId: targetGroupId, + tags: [], +} +``` + +Después de importar con éxito, en lugar de navegar a workspace: navegar de vuelta a `/` si el usuario vino sin groupId, o puede navegar directamente al workspace (comportamiento actual — mantener). + +### Archivos de tests con objetos `Process` o `Activity` literales + +Buscar todos los archivos bajo `tests/` que construyan objetos `{ id: ..., name: ..., bpmnXml: ... }` como `Process` o similar. Agregar `groupId: null, tags: []` a cada objeto `Process`. No agregar `automatedAssignedResources` todavía (campo sigue siendo opcional). + +Este es un scope amplio autorizado: **cualquier archivo de tests que falle compilación TypeScript por `Process.groupId` o `Process.tags` faltantes está autorizado a ser modificado para agregar esos campos con sus defaults**. + +--- + +## ❌ Lo que NO entra en esta etapa + +- Renombrar grupos (edición del nombre post-creación) — Sprint 3 o 4 +- Configuración de `groupLabel` en UI — Etapa 3 +- Tags en procesos (chip input en workspace) — Etapa 3 +- Drag & drop de procesos entre grupos — backlog +- Eliminar procesos desde la biblioteca — backlog +- Cambios en `WorkspacePage`, `ReportPage`, `ActivityPanel`, o cualquier feature de workspace +- Cambios en el motor de simulación o en exports PDF/CSV +- Cambios en `process-store.ts` o `simulation-store.ts` +- `Activity.automatedAssignedResources` sigue siendo opcional — no promover a requerido + +Si agregar `groupId`/`tags` como requeridos en `types.ts` genera errores TypeScript en archivos fuera de los autorizados: listar los archivos afectados y reportar antes de modificarlos. No resolver silenciosamente. Ver AP.9. + +--- + +## Verificación antes de entregar + +```bash +# 1. Build limpio +npm run build + +# 2. Tests (todos los previos deben seguir verdes) +npm run test + +# 3. Control de scope +git diff --name-only HEAD +``` + +El diff debe mostrar únicamente archivos de: +- `src/domain/types.ts` +- `src/persistence/repositories.ts` +- `src/store/library-store.ts` (nuevo) +- `src/features/library/` (nuevos) +- `src/router.tsx` +- `src/features/import/ImportPage.tsx` +- `tests/**` (solo los que tienen objetos `Process` literales que necesitan `groupId`/`tags`) + +Si aparece cualquier archivo de `src/features/workspace/`, `src/features/report/`, `src/store/process-store.ts`, o `src/lib/export/`: revertir y reportar. + +--- + +## Criterios de aceptación + +- [ ] LibraryPage es la home (`/`). ImportPage está en `/import`. +- [ ] Grupos se listan en grid con conteo, nombre y fecha de última modificación. +- [ ] "Nuevo cliente" crea un grupo inline (sin modal): click → input → Enter confirma / Escape cancela. +- [ ] "Sin clasificar" muestra procesos sin grupo. Oculto si no hay ninguno. +- [ ] Navegación home ↔ grupo funciona con breadcrumb. +- [ ] KPI badge en process card muestra: ahorro proyectado (verde), costo actual (neutro), o "Sin simular". +- [ ] Búsqueda en home filtra por nombre de proceso en tiempo real. +- [ ] Filtro por tags en modo AND funciona. +- [ ] "Importar BPMN" desde dentro de un grupo pasa `groupId` a ImportPage: el proceso queda asignado al grupo. +- [ ] `Process.groupId` y `Process.tags` son campos requeridos. Build sin errores TypeScript. +- [ ] Todos los tests previos siguen verdes. +- [ ] Validación visual del director aprobada. + +--- + +## Criterio de wow + +Los empty states y las transiciones visuales son la diferencia entre una herramienta que se siente terminada y una que se siente en construcción. Implementar: +- Empty state en home (primera vez sin grupos) con CTA prominente. +- Empty state en grupo vacío con mensaje contextual. +- Transición suave (CSS transition opacity/transform 150ms) al entrar y salir de un grupo. + +--- + +## Reporte esperado al terminar + +1. Resultado de `git diff --name-only HEAD` con la lista de archivos modificados. +2. Cantidad de archivos de tests actualizados con defaults `groupId`/`tags`. +3. Resultado de `npm run build` y `npm run test`. +4. Cualquier decisión de iniciativa tomada (Nivel 1/2/3). +5. Si hubo algún archivo fuera del scope que requirió modificación: declararlo explícitamente con justificación. + +--- + +## Referencias + +- `sprints/sprint-3/BRIEF.md` — sección 4 (Feature B) +- `src/store/process-store.ts` — patrón de Zustand a seguir +- `src/persistence/repositories.ts` — patrón de repos a seguir +- `src/router.tsx` — estado actual del routing +- `src/features/import/ImportPage.tsx` — construcción actual de objetos Process +- `methodology/ANTI_PATTERNS.md` — AP.9 (restricciones contradictorias) +- `methodology/PATTERNS.md` — C.7 (git diff como control preventivo) diff --git a/sprints/sprint-3/ETAPA_3_PROMPT.md b/sprints/sprint-3/ETAPA_3_PROMPT.md new file mode 100644 index 0000000..8a85454 --- /dev/null +++ b/sprints/sprint-3/ETAPA_3_PROMPT.md @@ -0,0 +1,242 @@ +# Sprint 3 — Etapa 3: Tags + búsqueda + configuración de groupLabel + +## Contexto + +Sos Claude Code trabajando en **InQ ROI**. Las reglas de implementación están en `simulador-web/CLAUDE.md`. El brief del sprint está en `sprints/sprint-3/BRIEF.md`. + +**Estado al iniciar esta etapa:** +- Etapa 1 completada: Dexie v4 en producción. `Process.groupId` y `Process.tags` requeridos. `Activity.automatedAssignedResources?` opcional. +- Etapa 2 completada: `LibraryPage` es la home (`/`). `ImportPage` en `/import`. `library-store.ts` con `groups`, `processes`, `settings`. `groupRepo` y `settingsRepo` en `repositories.ts`. La biblioteca muestra grupos, "Sin clasificar", búsqueda global y filtro por tags en modo AND. + +**Esta etapa agrega la edición de tags desde el workspace, la configuración de `groupLabel`, y una SettingsPage accesible desde la biblioteca.** + +--- + +## ⚠️ Tarea preflight — verificar antes de cualquier trabajo nuevo + +Antes de escribir código nuevo, verificar y corregir el siguiente bug en `src/features/library/LibraryPage.tsx`: + +**Bug:** El contador de clientes en el header (`"N procesos · N clientes"`) usa un campo heredado: + +```typescript +// LÍNEA ~524 — código actual (INCORRECTO): +const totalClients = new Set(processes.map((p) => p.clientName).filter(Boolean)).size +``` + +`clientName` es un campo del modelo anterior que ya no refleja la agrupación por grupos. El contador no se actualiza cuando se crea un nuevo grupo porque ningún proceso cambia de `clientName`. + +**Fix requerido:** + +```typescript +// CORRECTO: +const { load, isLoading, processes, groups } = useLibraryStore() +// ... +const totalClients = groups.length +``` + +Verificar también que el destructuring en la línea `const { load, isLoading, processes } = useLibraryStore()` (dentro de `LibraryPage`) incluya `groups`. + +**Clasificación de iniciativa:** Nivel 2 — cambio visible al usuario (el contador ahora refleja grupos reales). Reportar en el reporte final. + +--- + +## Objetivo + +1. **Tags en WorkspacePage:** chip input para agregar y quitar tags al proceso actual. Con autocompletado de tags existentes (case-insensitive). Persiste via `updateProcess({ tags: [...] })`. +2. **Configuración de `groupLabel`:** nueva `SettingsPage` en ruta `/settings`. Permite cambiar el label del nivel de agrupación. Persiste en `AppSettings`. Reactivo: la UI entera refleja el cambio sin recargar. +3. **Acceso a settings desde biblioteca:** ícono ⚙ en el header de `LibraryPage` que navega a `/settings`. +4. **`updateSettings` en `library-store.ts`:** nueva acción que actualiza `AppSettings` en Dexie y en el estado Zustand. +5. **Verificar filtro de tags en biblioteca:** el filtro AND ya existe desde Etapa 2 — confirmar que funciona correctamente con tags reales (test o validación manual). + +--- + +## Cambios autorizados y arquitectura + +### `src/store/library-store.ts` + +Agregar la acción `updateSettings` a la interfaz y al store: + +```typescript +interface LibraryState { + // ... existentes ... + updateSettings: (updates: Partial>) => Promise + getAllTags: () => string[] // computed — retorna tags únicos de todos los procesos (sin async) +} +``` + +Implementación: + +```typescript +updateSettings: async (updates) => { + await settingsRepo.update(updates) + set((state) => ({ settings: { ...state.settings, ...updates } })) +}, + +getAllTags: () => { + const set = new Set() + get().processes.forEach((p) => p.tags.forEach((t) => set.add(t))) + return [...set].sort() +}, +``` + +`getAllTags()` es sincrónico porque `processes` ya está cargado en el store desde `load()`. Si el store no está cargado aún, retorna `[]` — el componente maneja este caso. + +### `src/features/workspace/WorkspacePage.tsx` + +Agregar chip input de tags justo **después del campo `clientName`** (línea ~220 en el archivo actual, después del bloque `{/* Cliente — editable inline */}`). + +**Comportamiento:** +- Tags existentes del proceso aparecen como chips removibles con ícono `×`. +- Input inline para agregar nuevos tags: placeholder `"Agregar tag..."`. +- Al escribir, mostrar dropdown con sugerencias de tags existentes (filtradas por lo que se escribe, case-insensitive). Máximo 6 sugerencias. +- Confirmar tag: `Enter` o click en sugerencia. +- Si el tag ingresado ya existe en el proceso (case-insensitive): ignorar silenciosamente. +- Normalización: guardar tags en lowercase para evitar fragmentación (`"BPMN"` → `"bpmn"`). +- Quitar tag: click en el `×` del chip. +- Guardar: llamar `updateProcess({ tags: newTags })` después de cada cambio (add o remove). + +**Fuente de tags para autocompletado:** `useLibraryStore().getAllTags()`. Si `library-store` no está inicializado aún (puede ocurrir si el usuario navega directo a `/workspace/...`), hacer `load()` perezoso o simplemente mostrar sugerencias vacías — no bloquear la UI. + +**Nota de arquitectura:** `WorkspacePage` importa de `useProcessStore` para mutaciones y puede importar de `useLibraryStore` para leer la lista de tags globales. Esto es un import de lectura entre stores — aceptable. **No** importar directamente de `repositories.ts`. + +**Estilo de chips:** +- Chip de tag: `text-[10px] px-2 py-0.5 rounded-full bg-secondary border border-border text-secondary-foreground`. Mismo estilo que en `ProcessCard` (ya existe en `LibraryPage.tsx`). +- Input de agregar: `text-xs bg-transparent border-b border-muted outline-none w-24` — línea subtil, sin box, para no romper el layout del sidebar. +- Dropdown de sugerencias: posición absolute, `bg-background border border-border rounded-md shadow-sm`, ancho mínimo del input. Z-index 50. + +### `src/features/settings/SettingsPage.tsx` (NUEVO) + +Nueva ruta `/settings`. Componente simple, sin tabs en esta etapa. + +**Layout:** +- Header: título `"Configuración"` (18px, weight 500), subtítulo `"Personalización del workspace"` (12px, color tertiary). +- Botón `"← Volver"` que navega a `/`. Color `#F59845`. +- Sección `"Biblioteca"`: + - Label: `"Etiqueta de grupos"` (13px, weight 500). + - Descripción: `"Cómo se llama el nivel de agrupación en la biblioteca. Por defecto: 'Cliente'."` (12px, color tertiary). + - Input de texto con el valor actual de `settings.groupLabel`. Al blur o al hacer Enter: guardar via `updateSettings({ groupLabel: value.trim() || 'Cliente' })`. + - Preview inline: `"Los procesos se organizarán por [valor]."` que se actualiza mientras se escribe (sin guardar hasta blur/Enter). +- Si el input está vacío o en blanco al salir del foco: revertir a `'Cliente'` (valor default). + +**Reactividad:** Como `settings` vive en `useLibraryStore`, cualquier componente que lea `settings.groupLabel` del store se actualiza automáticamente al hacer `updateSettings`. Verificar que `LibraryPage`, `HomeView`, `GroupView` y `GroupCard` usan `settings.groupLabel` del store (ya deberían desde Etapa 2). + +### `src/router.tsx` + +Agregar ruta `/settings`: + +```typescript +import { SettingsPage } from '@/features/settings/SettingsPage' + +const settingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings', + component: SettingsPage, +}) + +// Agregar settingsRoute al routeTree +``` + +### `src/features/library/LibraryPage.tsx` + +Dos cambios: +1. Fix del bug del contador (ver sección preflight). +2. Agregar ícono de settings en el header: botón con ícono `Settings` (Lucide) que navega a `/settings`. Posicionarlo a la izquierda del botón "Importar BPMN", con estilo ghost (sin fondo, color `text-muted-foreground`, hover `text-foreground`). + +--- + +## Versión con wow (recomendada) + +Si el costo de implementación es < 40% sobre la versión mínima, agregar: + +**Tag cloud en home de biblioteca:** +- Visible cuando hay al menos 3 tags distintos en la base y no hay búsqueda activa. +- Ubicación: entre la barra de búsqueda y la sección de grupos (igual que la fila de tags actual). +- Los chips de tags muestran tamaño de fuente proporcional a la frecuencia de uso: tags con más procesos → ligeramente más grandes (rango: `text-[10px]` a `text-[13px]`). Máximo 12 tags. +- Click en un chip activa el filtro AND (comportamiento idéntico al existente). +- Si ya hay una fila de tags (línea `{tags.length > 0 && ...}`), reemplazarla con el tag cloud. No duplicar. + +**Autocompletado fuzzy en WorkspacePage:** +- En lugar de filtrar por `startsWith`, usar `includes` (substring match). Ejemplo: escribir `"proc"` sugiere `"proceso"` y `"subprocess"`. + +--- + +## ❌ Lo que NO entra en esta etapa + +- Renombrar o eliminar grupos desde la biblioteca — pendiente +- Eliminar procesos desde la biblioteca — backlog +- Múltiples niveles de etiquetas configurables — Sprint 4+ +- Indicador de simulación desactualizada — Etapa 4 +- Zoom/fit canvas BPMN — Etapa 4 +- Recursos en escenario automatizado — Etapa 5 +- Cambios en el motor de simulación, export PDF/CSV +- Cambios en `process-store.ts` o `simulation-store.ts` +- `Activity.automatedAssignedResources` sigue siendo opcional — no promover a requerido + +--- + +## Verificación antes de entregar + +```bash +# 1. Build limpio +npm run build + +# 2. Tests (todos los previos deben seguir verdes) +npm run test + +# 3. Control de scope +git diff --name-only HEAD +``` + +El diff debe mostrar únicamente archivos de: +- `src/features/library/LibraryPage.tsx` (fix bug contador + botón settings) +- `src/store/library-store.ts` (nuevas acciones `updateSettings`, `getAllTags`) +- `src/features/workspace/WorkspacePage.tsx` (chip input de tags) +- `src/features/settings/SettingsPage.tsx` (nuevo) +- `src/router.tsx` (nueva ruta `/settings`) + +Si aparece cualquier archivo de `src/features/report/`, `src/lib/export/`, `src/domain/simulation.ts`, `src/persistence/db.ts`, `src/store/process-store.ts`, o `src/store/simulation-store.ts`: **revertir y reportar**. + +--- + +## Criterios de aceptación + +- [ ] Bug preflight resuelto: el contador `"N clientes"` refleja `groups.length` y se actualiza al crear un grupo. +- [ ] Tags del proceso se muestran como chips removibles en el sidebar de WorkspacePage. +- [ ] Agregar tag funciona: Enter confirma, el tag se guarda en el proceso (persiste al recargar). +- [ ] Autocompletado muestra tags existentes (case-insensitive, substring match en versión wow). +- [ ] Tags normalizados a lowercase al guardar. +- [ ] SettingsPage en `/settings` permite cambiar `groupLabel`. +- [ ] El nuevo label se refleja en toda la UI sin recargar (reactivo via Zustand). +- [ ] Botón ⚙ en LibraryPage navega a `/settings`. +- [ ] Build sin errores TypeScript. +- [ ] Todos los tests previos siguen verdes. +- [ ] Validación visual del director aprobada. + +--- + +## Criterio de wow + +Un chip input bien ejecutado es la diferencia entre "edito tags" y "organizo mis procesos naturalmente". El autocomplete que aparece mientras escribís y desaparece sin molestar al salir del foco marca esa diferencia. Apuntar a esa experiencia. + +--- + +## Reporte esperado al terminar + +1. Confirmación del fix del bug preflight (línea exacta modificada). +2. Resultado de `git diff --name-only HEAD` con la lista de archivos modificados. +3. Resultado de `npm run build` y `npm run test`. +4. Si se implementó versión wow (tag cloud, fuzzy): declararlo. +5. Cualquier decisión de iniciativa tomada (Nivel 1/2/3). +6. Si hubo algún archivo fuera del scope que requirió modificación: declararlo explícitamente con justificación. + +--- + +## Referencias + +- `sprints/sprint-3/BRIEF.md` — sección 4, Feature C +- `src/features/library/LibraryPage.tsx` — estructura actual de la home (chip de tags ya existe en `ProcessCard`) +- `src/features/workspace/WorkspacePage.tsx` — bloque `clientName` editable (~línea 192-220) — el chip input de tags va después +- `src/store/library-store.ts` — store a extender con `updateSettings` y `getAllTags` +- `src/persistence/repositories.ts` — `settingsRepo.update` ya existe +- `methodology/ANTI_PATTERNS.md` — AP.8 (adelanto silencioso), AP.9 (restricciones contradictorias) +- `methodology/PATTERNS.md` — C.7 (git diff como control preventivo) diff --git a/sprints/sprint-3/ETAPA_4_PROMPT.md b/sprints/sprint-3/ETAPA_4_PROMPT.md new file mode 100644 index 0000000..1829638 --- /dev/null +++ b/sprints/sprint-3/ETAPA_4_PROMPT.md @@ -0,0 +1,307 @@ +# Sprint 3 — Etapa 4: Indicador stale + zoom BPMN + +## Contexto + +Sos Claude Code trabajando en **InQ ROI**. Las reglas de implementación están en `simulador-web/CLAUDE.md`. El brief del sprint está en `sprints/sprint-3/BRIEF.md`. + +**Estado al iniciar esta etapa:** +- Etapas 1-3 completadas. Biblioteca de procesos operativa. Tags y búsqueda funcionando. `SettingsPage` disponible. +- `process-store.ts` expone: `updateActivity`, `updateGateway`, `updateResource`, `addResource`, `deleteResource`, `updateProcess`. Ninguna de estas acciones actualiza `process.updatedAt` excepto `updateProcess`. +- `simulation-store.ts` tiene `lastSimulatedAt: Date | null` (in-memory, se pierde al recargar) y `persistResults` (guarda en IndexedDB con `executedAt: Date.now()`). +- `BpmnCanvas.tsx`: viewer bpmn-js puro, sin toolbar, retorna un `

` que es el contenedor del canvas. Ya llama `canvas.zoom('fit-viewport', 'auto')` al importar el XML. + +**Esta etapa agrega dos features independientes:** +1. **Indicador stale** — aviso persistente cuando el proceso tiene cambios sin simular. +2. **Botón fit-viewport** — encuadrar el diagrama BPMN en cualquier momento. + +--- + +## Objetivo + +**D.1 — Indicador de simulación desactualizada** + +Cuando el usuario modifica actividades, recursos o gateways sin volver a simular, el workspace muestra un indicador visible y persistente (sobrevive a recargas de página). + +**D.2 — Botón fit-viewport en BpmnCanvas** + +Un botón flotante sobre el canvas que ejecuta `canvas.zoom('fit-viewport')`. Detectado en validación Sprint 2 como necesidad real en talleres con diagramas grandes. + +--- + +## Arquitectura — leer antes de implementar + +### Dependencias entre stores (no crear dependencias circulares) + +``` +process-store.ts + ↑ importado por +simulation-store.ts (excepción documentada en el archivo) +``` + +Para evitar dependencia circular, **`process-store.ts` NO debe importar `simulation-store.ts`**. La coordinación entre stores la hace `WorkspacePage`. + +### Flujo correcto para el indicador stale + +1. `process-store.ts` — bump `process.updatedAt` en mutaciones de actividades/gateways/recursos. +2. `simulation-store.ts` — agregar `lastPersistedExecutedAt: number | null` (cargado desde IndexedDB, sobrevive recargas) y la acción `loadLatestForProcess(processId)`. +3. `WorkspacePage` — orquesta: llama `loadLatestForProcess` cuando el proceso está cargado, y computa `isStale` comparando los dos valores. + +--- + +## Cambios autorizados y arquitectura + +### `src/store/process-store.ts` + +Agregar bump de `process.updatedAt` en todas las acciones que mutan datos del proceso sin ser `updateProcess`: + +```typescript +// Patrón a aplicar en: updateActivity, updateGateway, updateResource, addResource, deleteResource +// (updateProcess ya actualiza updatedAt correctamente — no modificar) + +// Ejemplo — updateActivity (aplicar el mismo patrón a las demás): +updateActivity: async (activity) => { + await activityRepo.save(activity) + const current = get().currentProcess + if (current) { + const updatedProcess = { ...current, updatedAt: Date.now() } + await processRepo.save(updatedProcess) + set((state) => ({ + activities: state.activities.map((a) => (a.id === activity.id ? activity : a)), + currentProcess: updatedProcess, + })) + } else { + set((state) => ({ + activities: state.activities.map((a) => (a.id === activity.id ? activity : a)), + })) + } +}, +``` + +**Nota:** la suscripción en `simulation-store.ts` ya llama `invalidateResults()` cuando `currentProcess` cambia. Esto es correcto y esperado — no modificar esa suscripción. + +### `src/store/simulation-store.ts` + +Agregar campo y acción para la comparación persistente: + +```typescript +interface SimulationState { + // ... campos existentes ... + lastPersistedExecutedAt: number | null // 🆕 executedAt de la última sim guardada en IndexedDB + loadLatestForProcess: (processId: string) => Promise // 🆕 +} +``` + +Implementación: + +```typescript +lastPersistedExecutedAt: null, + +loadLatestForProcess: async (processId) => { + const sim = await simulationRepo.getLatestByProcess(processId) + set({ lastPersistedExecutedAt: sim?.executedAt ?? null }) +}, +``` + +Actualizar también `persistResults` para mantener el campo sincronizado: + +```typescript +persistResults: async (processId, actual, automated) => { + const executedAt = Date.now() + await simulationRepo.save({ + id: uuidv4(), + processId, + executedAt, + result: actual, + resultAutomated: automated, + }) + set({ + resultActual: actual, + resultAutomated: automated, + lastSimulatedAt: new Date(), + lastPersistedExecutedAt: executedAt, // 🆕 + error: null, + }) +}, +``` + +Y limpiar en `reset`: +```typescript +reset: () => set({ ..., lastPersistedExecutedAt: null }) +``` + +### `src/features/workspace/WorkspacePage.tsx` + +**1 — Orquestación (nuevo `useEffect`)** + +Después de que el proceso se carga, cargar el último `executedAt` persistido: + +```typescript +const { loadLatestForProcess, lastPersistedExecutedAt } = useSimulationStore() + +useEffect(() => { + if (currentProcess) { + loadLatestForProcess(currentProcess.id) + } +}, [currentProcess?.id, loadLatestForProcess]) +``` + +**2 — Cómputo del indicador** + +```typescript +const isStale = Boolean( + currentProcess && + currentProcess.updatedAt > (lastPersistedExecutedAt ?? 0) +) +``` + +**3 — UI del indicador** + +Mostrar un badge sutil **junto al botón Simular** cuando `isStale === true`. El botón Simular ya existe en el header del workspace. + +Diseño del indicador: +- Chip naranja claro: fondo `#fff3e6`, borde `#F59845`, texto `#7a3e00`, ícono `AlertCircle` (Lucide, `h-3 w-3`). +- Texto: `"Hay cambios sin simular"`. +- Ubicación: inline junto al botón Simular, o como fila adicional debajo del header — a criterio del agente según el layout actual (Nivel 1). +- Visible solo cuando `isStale === true`. Desaparece al completar la simulación (porque `persistResults` actualiza `lastPersistedExecutedAt`). + +### `src/features/workspace/BpmnCanvas.tsx` + +Agregar botón flotante de fit-viewport. El canvas actualmente retorna: + +```tsx +return
+``` + +Cambiar a wrapper con botón superpuesto: + +```tsx +return ( +
+
+ +
+) +``` + +Importar `Maximize2` desde `lucide-react`. + +--- + +## Versión con wow (recomendada si costo < 40% del mínimo) + +**Banner en ReportPage:** + +En `src/features/report/ReportPage.tsx`, si el proceso tiene cambios sin simular, mostrar un banner sutil en la parte superior del reporte: + +``` +⚠️ Los resultados pueden estar desactualizados — hay cambios sin simular. + [Ir al workspace →] +``` + +Diseño: fondo `#fffbeb`, borde `#f59e0b`, texto `#92400e` (amber). Full-width, 40px de alto máximo. Link "Ir al workspace →" navega a `/workspace/$processId`. + +`ReportPage` ya usa `useProcessStore` para leer `currentProcess`. Necesita también `useSimulationStore().lastPersistedExecutedAt` para el cálculo de `isStale`. + +--- + +## ❌ Lo que NO entra en esta etapa + +- Cambios en `ActivityPanel.tsx`, `ResourcesPanel.tsx`, `GatewayPanel.tsx` — no tocar +- `automatedAssignedResources` — sigue siendo opcional, no promover (Etapa 5) +- Cambios en el motor de simulación (`domain/simulation.ts`) +- Export PDF/CSV +- Biblioteca o library-store +- Tags, settings + +--- + +## Verificación antes de entregar + +```bash +# 1. Build limpio +npm run build + +# 2. Tests (todos los previos deben seguir verdes) +npm run test + +# 3. Control de scope +git diff --name-only HEAD +``` + +El diff debe mostrar únicamente: +- `src/store/process-store.ts` +- `src/store/simulation-store.ts` +- `src/features/workspace/WorkspacePage.tsx` +- `src/features/workspace/BpmnCanvas.tsx` +- `src/features/report/ReportPage.tsx` (solo si se implementó wow) + +Si aparece cualquier otro archivo de `src/features/` que no sea workspace/ o report/: revertir y reportar. + +### Verificación funcional mandatoria + +Antes de reportar, verificar manualmente: + +1. **Stale persiste en recarga:** modificar una actividad → recargar página → el indicador sigue visible → simular → desaparece. +2. **Sin sim previa:** proceso recién importado sin simular nunca → indicador visible (ya que `updatedAt > 0 > null ?? 0`). +3. **Fit-viewport:** abrir un diagrama complejo → click en el botón → el canvas se encuadra. +4. **Sin falsos positivos:** proceso recién simulado sin cambios posteriores → indicador ausente. + +--- + +## Criterios de aceptación + +- [ ] Modificar actividad/recurso/gateway actualiza `process.updatedAt` en IndexedDB. +- [ ] Indicador "Hay cambios sin simular" aparece en WorkspacePage al detectar `isStale`. +- [ ] Indicador desaparece al simular exitosamente. +- [ ] Indicador persiste al recargar la página (comparación contra IndexedDB, no solo memoria). +- [ ] Proceso sin historial de simulación → indicador visible. +- [ ] Botón fit-viewport en BpmnCanvas encuadra el diagrama correctamente. +- [ ] Build sin errores TypeScript. +- [ ] Todos los tests previos siguen verdes. +- [ ] Validación visual del director aprobada. + +--- + +## Reporte esperado al terminar + +1. Resultado de `git diff --name-only HEAD`. +2. Resultado de `npm run build` y `npm run test`. +3. Confirmación de la verificación funcional (4 casos). +4. Si se implementó el banner en ReportPage (wow): declararlo. +5. Cualquier decisión de iniciativa tomada (Nivel 1/2/3). +6. Si hubo archivo fuera del scope: declararlo con justificación. + +--- + +## Referencias + +- `sprints/sprint-3/BRIEF.md` — sección 4, Feature D +- `src/store/process-store.ts` — acciones a modificar: `updateActivity`, `updateGateway`, `updateResource`, `addResource`, `deleteResource` +- `src/store/simulation-store.ts` — estado actual, suscripción a process-store +- `src/features/workspace/BpmnCanvas.tsx` — canvas actual, sin toolbar +- `methodology/ANTI_PATTERNS.md` — AP.8 (adelanto silencioso) +- `methodology/PATTERNS.md` — C.7 (git diff como control preventivo) diff --git a/sprints/sprint-3/ETAPA_5_PROMPT.md b/sprints/sprint-3/ETAPA_5_PROMPT.md new file mode 100644 index 0000000..38230e1 --- /dev/null +++ b/sprints/sprint-3/ETAPA_5_PROMPT.md @@ -0,0 +1,445 @@ +# Sprint 3 — Etapa 5: Recursos en escenario automatizado + +## Contexto + +Sos Claude Code trabajando en **InQ ROI**. Las reglas de implementación están en `simulador-web/CLAUDE.md`. El brief del sprint está en `sprints/sprint-3/BRIEF.md`. + +**Estado al iniciar esta etapa:** +- Etapas 1–4 completadas. Dexie v4 activo. Biblioteca operativa con grupos, tags, búsqueda. Indicador stale funcionando (persiste en recarga). Botón fit-viewport en BpmnCanvas. +- `Activity.automatedAssignedResources?: ActivityResourceAssignment[]` existe en `types.ts` como campo opcional desde Etapa 1. La migración v4 ya popula las actividades existentes con `[]`. +- `simulation.ts` actualmente usa `activity.assignedResources` en ambos escenarios. La rama `useAutomated` solo cambia `directCostFixed` y `executionTimeMinutes`, pero **no cambia los recursos** — bug intencional pendiente de esta etapa. +- `ActivityPanel.tsx` tiene la sección "Automatización" con toggle, `automatedCostFixed` e `automatedTimeMinutes`. Los recursos automatizados no tienen UI todavía. +- `ActivitiesTable.tsx` recibe `perActivity: ActivitySimResult[]` y muestra `resourceCostBreakdown` en filas expandibles. + +**Esta etapa conecta las tres capas: motor de simulación, UI de edición, y visualización en reporte.** + +--- + +## Objetivo + +**E.1 — Motor de simulación** +Cuando `useAutomated = true`, usar `activity.automatedAssignedResources ?? []` en lugar de `activity.assignedResources` para el cálculo de costos de recursos. + +**E.2 — UI en ActivityPanel** +Agregar subsección "Recursos TO-BE" dentro de la sección Automatización del ActivityPanel (visible cuando `automatable = true`). Mismo patrón de asignación que los recursos AS-IS. + +**E.3 — Reporte web (versión mínima)** +Sin cambios necesarios: cuando el motor usa `automatedAssignedResources`, el `resourceCostBreakdown` de `SimulationResult` ya refleja correctamente los recursos del escenario automatizado. Las filas expandibles existentes en `ActivitiesTable` muestran el breakdown del escenario que corresponde a cada tab. + +**E.3 wow — Comparación lado a lado** +En el tab "Automatizado", las filas expandibles de actividades automatizables muestran AMBOS breakdowns: "Recursos AS-IS" y "Recursos TO-BE", para facilitar la comparación. + +--- + +## Arquitectura — leer antes de implementar + +### `automatedAssignedResources` sigue siendo opcional en types.ts + +**No cambiar `types.ts` en esta etapa.** El campo permanece como `automatedAssignedResources?: ActivityResourceAssignment[]`. La promoción a requerido + actualización de tests va en Etapa 6 para hacer el cambio en un solo paso limpio. + +Consecuencia: en todo el código nuevo usar `activity.automatedAssignedResources ?? []` para manejar el caso de actividades antiguas que podrían no tener el campo. + +### Dependencias autorizadas + +`ActivityPanel` ya importa de `useProcessStore` para leer `resources`. No necesita importar nuevas stores para esta feature. + +--- + +## Cambios autorizados y arquitectura + +### `src/domain/simulation.ts` + +**Cambio mínimo: usar los recursos correctos según el escenario.** + +Localizar el bloque de la línea ~207: +```typescript +// ACTUAL (usa siempre assignedResources): +for (const assignment of activity.assignedResources) { +``` + +Reemplazar por: +```typescript +// CORRECTO: seleccionar el array según el escenario +const resourceAssignments = useAutomated + ? (activity.automatedAssignedResources ?? []) + : activity.assignedResources + +for (const assignment of resourceAssignments) { +``` + +Este es el único cambio en `simulation.ts`. No tocar nada más en este archivo. + +### `src/features/import/ImportPage.tsx` + +Agregar `automatedAssignedResources: []` a la construcción de actividades (~línea 89): + +```typescript +const activities: Activity[] = activityElements.map((el) => ({ + id: uuidv4(), + processId, + bpmnElementId: el.bpmnElementId, + name: el.name, + type: el.type, + directCostFixed: 0, + executionTimeMinutes: 0, + assignedResources: [], + automatable: false, + automatedCostFixed: 0, + automatedTimeMinutes: 0, + automatedAssignedResources: [], // 🆕 +})) +``` + +**Clasificación de iniciativa:** Nivel 1 — compatibilidad preventiva, no hay cambio visible. + +### `src/features/workspace/ActivityPanel.tsx` + +#### 1 — Inicialización del estado local + +Actualizar el `setLocalActivity` inicial para incluir `automatedAssignedResources`: + +```typescript +setLocalActivity(activity ? { + ...activity, + assignedResources: [...activity.assignedResources], + automatedAssignedResources: [...(activity.automatedAssignedResources ?? [])], +} : null) +``` + +#### 2 — Handlers de recursos automatizados + +Agregar tres handlers paralelos a los de AS-IS (cerca de `addResource`, `removeResource`, `updateResourceAssignment`): + +```typescript +const addAutomatedResource = (resourceId: string) => { + if (!localActivity) return + const already = (localActivity.automatedAssignedResources ?? []).some( + (r) => r.resourceId === resourceId + ) + if (already) return + handleChange('automatedAssignedResources', [ + ...(localActivity.automatedAssignedResources ?? []), + { resourceId, utilizationMinutes: 60, units: 1 }, + ]) +} + +const removeAutomatedResource = (resourceId: string) => { + if (!localActivity) return + handleChange( + 'automatedAssignedResources', + (localActivity.automatedAssignedResources ?? []).filter((r) => r.resourceId !== resourceId) + ) +} + +const updateAutomatedAssignment = ( + resourceId: string, + field: 'utilizationMinutes' | 'units', + value: number +) => { + if (!localActivity) return + handleChange( + 'automatedAssignedResources', + (localActivity.automatedAssignedResources ?? []).map((r) => + r.resourceId === resourceId ? { ...r, [field]: value } : r + ) + ) +} +``` + +#### 3 — Dropdown de recursos disponibles para TO-BE + +```typescript +const availableAutomatedResources = resources.filter( + (r) => !(localActivity.automatedAssignedResources ?? []).some((ar) => ar.resourceId === r.id) +) +``` + +#### 4 — Costo total de recursos TO-BE (para mostrar en UI) + +```typescript +const localAutomatedResourceCost = (localActivity.automatedAssignedResources ?? []).reduce( + (total, assignment) => { + const r = resources.find((res) => res.id === assignment.resourceId) + if (!r) return total + return total + r.costPerHour * (assignment.utilizationMinutes / 60) * assignment.units + }, + 0 +) +``` + +#### 5 — UI dentro del contenedor de Automatización + +**Cambio requerido previo:** el contenedor animado actual tiene `max-h-80` (320px), insuficiente para el nuevo contenido. Cambiar a `max-h-[640px]`. + +Dentro del contenedor animado (después de los inputs `automatedCostFixed` y `automatedTimeMinutes`), agregar: + +```tsx +{/* Separator + Recursos TO-BE */} + + +
+
+

+ Recursos TO-BE +

+ {/* Dropdown para agregar recurso */} + {availableAutomatedResources.length > 0 && ( + + )} +
+ + {/* Lista de recursos asignados al escenario automatizado */} + {(localActivity.automatedAssignedResources ?? []).length === 0 ? ( +

+ Sin recursos TO-BE — solo costo fijo automatizado. +

+ ) : ( +
+ {(localActivity.automatedAssignedResources ?? []).map((assignment) => { + const resource = resources.find((r) => r.id === assignment.resourceId) + if (!resource) return null + const cost = resource.costPerHour * (assignment.utilizationMinutes / 60) * assignment.units + return ( +
+
+ {resource.name} +
+ + {formatCurrency(cost, currency)} + + +
+
+ {/* Utilización y unidades */} +
+
+

Min. por ejecución

+ + updateAutomatedAssignment( + assignment.resourceId, + 'utilizationMinutes', + Math.max(0, parseFloat(e.target.value) || 0) + ) + } + className="h-6 text-xs font-mono" + /> +
+
+

Unidades

+ + updateAutomatedAssignment( + assignment.resourceId, + 'units', + Math.max(1, parseInt(e.target.value) || 1) + ) + } + className="h-6 text-xs font-mono" + /> +
+
+
+ ) + })} +
+ )} +
+``` + +**Nota:** Si `resources` (el catálogo del proceso) está vacío, el dropdown no aparece y la sección muestra solo el empty state. Esto es correcto — el usuario primero carga recursos en el panel "Recursos" y luego los asigna aquí. + +#### 6 — Actualizar el helper ámbar + +El helper ámbar existente muestra cuando `automatedCostFixed === 0 && automatedTimeMinutes === 0`. Ahora también debe considerar recursos: + +```typescript +const showHelper = localActivity.automatable + && localActivity.automatedCostFixed === 0 + && localActivity.automatedTimeMinutes === 0 + && (localActivity.automatedAssignedResources ?? []).length === 0 +``` + +--- + +## Versión con wow — Comparación lado a lado en reporte + +**Implementar si el costo extra es < 40% del mínimo.** + +El objetivo: en el tab "Automatizado" del reporte, las filas expandibles de actividades automatizables muestran el desglose de recursos de AMBOS escenarios. + +### `src/features/report/ActivitiesTable.tsx` + +Agregar prop opcional: + +```typescript +interface ActivitiesTableProps { + perActivity: ActivitySimResult[] + pairedBreakdown?: Record> + // ...props existentes sin cambio... +} +``` + +En la fila expandida, si `pairedBreakdown` tiene data para esta actividad Y la actividad tiene su propio breakdown, mostrar dos secciones: + +```tsx +{/* En la fila expandida — cuando hay pairedBreakdown para esta actividad */} +{pairedBreakdown?.[act.activityId] && ( + <> +

AS-IS

+ {/* Lista del pairedBreakdown (recursos AS-IS) */} + {pairedBreakdown[act.activityId].map((r) => ( +
+ {r.resourceName} + {formatCurrency(r.cost, currency)} +
+ ))} +

TO-BE

+ {act.resourceCostBreakdown.length === 0 + ?

Sin recursos TO-BE

+ : act.resourceCostBreakdown.map((r) => ( +
+ {r.resourceName} + {formatCurrency(r.cost, currency)} +
+ )) + } + +)} +``` + +Si no hay `pairedBreakdown`, el comportamiento existente se mantiene sin cambios. + +### `src/features/report/ReportPage.tsx` + +Agregar el cálculo del breakdown pareado: + +```typescript +const actualBreakdownById = useMemo(() => { + if (!simulation?.result) return undefined + return Object.fromEntries( + simulation.result.perActivity.map((a) => [a.activityId, a.resourceCostBreakdown]) + ) +}, [simulation?.result]) +``` + +En el `ActivitiesTable` del tab "Automatizado", pasar el prop: +```tsx + +``` + +--- + +## ❌ Lo que NO entra en esta etapa + +- `src/domain/types.ts` — no modificar. El campo `automatedAssignedResources?` permanece opcional hasta Etapa 6. +- Tests nuevos — van en Etapa 6 junto con la promoción del campo a requerido. +- `src/store/process-store.ts` — no tocar. +- `src/store/simulation-store.ts` — no tocar. +- `src/persistence/db.ts` — no tocar (migración ya hecha). +- PDF — va en Etapa 6. +- `ActivityPanel.tsx` sección AS-IS de recursos — no modificar la sección existente, solo agregar la sección TO-BE. +- `ResourcesPanel.tsx` — no tocar. +- `GatewayPanel.tsx` — no tocar. + +--- + +## Verificación antes de entregar + +```bash +# 1. Build limpio +npm run build + +# 2. Tests (todos los previos deben seguir verdes) +npm run test + +# 3. Control de scope +git diff --name-only HEAD +``` + +El diff debe mostrar únicamente: +- `src/domain/simulation.ts` +- `src/features/workspace/ActivityPanel.tsx` +- `src/features/import/ImportPage.tsx` +- `src/features/report/ActivitiesTable.tsx` (solo si se implementó wow) +- `src/features/report/ReportPage.tsx` (solo si se implementó wow) + +Si aparece `src/domain/types.ts`, `src/store/`, `src/persistence/`, o cualquier panel que no sea ActivityPanel: **revertir y reportar**. + +### Verificación funcional mandatoria + +Antes de reportar, verificar manualmente: + +1. **Recursos AS-IS no contaminan TO-BE:** asignar recursos en AS-IS → simular → el tab Automatizado NO debe incluir esos recursos si `automatedAssignedResources` está vacío. +2. **Recursos TO-BE correctos:** asignar un recurso en TO-BE (en ActivityPanel) → simular → el tab Automatizado MUESTRA ese recurso en el breakdown de la actividad. +3. **Mixto funciona:** actividad con `automatedCostFixed = 500` + un recurso TO-BE → el costo automatizado total = 500 + costo del recurso. +4. **Actividad no automatizable:** recursos AS-IS se usan en ambos escenarios (comportamiento anterior sin cambio). +5. **Sin recursos TO-BE:** proceso sin recursos asignados en TO-BE → el motor no crashea, muestra 0 en recursos del tab Automatizado. + +--- + +## Criterios de aceptación + +- [ ] Motor de simulación usa `automatedAssignedResources` al calcular escenario automatizado. +- [ ] Actividades no automatizables: comportamiento sin cambio (siguen usando `assignedResources`). +- [ ] Sección "Recursos TO-BE" visible en ActivityPanel cuando `automatable = true`. +- [ ] Asignar recurso TO-BE → Guardar → recargar página → recurso persiste. +- [ ] Dropdown de recursos TO-BE excluye los ya asignados. +- [ ] Costo TO-BE en reporte refleja recursos TO-BE + `automatedCostFixed`. +- [ ] `ImportPage` crea actividades con `automatedAssignedResources: []`. +- [ ] Build sin errores TypeScript. +- [ ] Todos los tests previos siguen verdes. +- [ ] Validación visual del director aprobada. + +--- + +## Reporte esperado al terminar + +1. Resultado de `git diff --name-only HEAD`. +2. Resultado de `npm run build` y `npm run test`. +3. Confirmación de los 5 casos de verificación funcional. +4. Si se implementó comparación lado a lado (wow): declararlo. +5. Cualquier decisión de iniciativa (Nivel 1/2/3). +6. Si apareció algún archivo fuera del scope: declararlo con justificación. + +--- + +## Referencias + +- `sprints/sprint-3/BRIEF.md` — sección 4, Feature E y sección 3.3 (fórmula de costo automatizado) +- `src/domain/simulation.ts` — línea ~207, loop de `assignedResources` +- `src/features/workspace/ActivityPanel.tsx` — sección Automatización (~línea 352), handlers de recursos AS-IS como referencia de patrón +- `src/features/report/ActivitiesTable.tsx` — prop interface y filas expandibles +- `src/features/report/ReportPage.tsx` — tabs actual/automatizado, uso de `simulation.result` y `simulation.resultAutomated` +- `methodology/ANTI_PATTERNS.md` — AP.8 (adelanto silencioso), AP.9 (restricciones contradictorias) +- `methodology/PATTERNS.md` — C.7 (git diff como control preventivo) diff --git a/sprints/sprint-3/ETAPA_6_PROMPT.md b/sprints/sprint-3/ETAPA_6_PROMPT.md new file mode 100644 index 0000000..fbd11fc --- /dev/null +++ b/sprints/sprint-3/ETAPA_6_PROMPT.md @@ -0,0 +1,270 @@ +# Sprint 3 — Etapa 6: PDF recursos automatizados + tests del sprint + +## Contexto + +Sos Claude Code trabajando en **InQ ROI**. Las reglas de implementación están en `simulador-web/CLAUDE.md`. El brief del sprint está en `sprints/sprint-3/BRIEF.md`. + +**Estado al iniciar esta etapa:** +- Etapas 1–5 completadas. Motor de simulación usa `automatedAssignedResources` correctamente. ActivityPanel tiene sección "Recursos TO-BE". Reporte web muestra comparación AS-IS/TO-BE en filas expandibles. +- `Activity.automatedAssignedResources` sigue siendo `?` (opcional) en `types.ts` — pendiente de esta etapa. +- `drawResourcesSection` en `pdf-sections.ts` lee `activityRow?.assignedResources` para los campos `utilizationMinutes` y `units`, incluso cuando se exporta el tab "automatizado". Esto es un bug: para el escenario automatizado debería leer `automatedAssignedResources`. +- Los factories de Activity en tests (`makeAutoActivity`, `makeActivity`) no incluyen `automatedAssignedResources`. Cuando hagamos el campo requerido, TypeScript los marcará con error. + +**Esta etapa cierra los tres pendientes del sprint: PDF correcto, campo promovido a requerido, tests del sprint.** + +--- + +## Objetivo + +**F.1 — PDF recursos automatizados** +Corregir `drawResourcesSection` para que cuando se exporta el escenario automatizado lea `automatedAssignedResources` (no `assignedResources`) al mostrar `utilizationMinutes` y `units` por fila. + +**F.2 — Promover campo a requerido** +Cambiar `automatedAssignedResources?: ActivityResourceAssignment[]` → `automatedAssignedResources: ActivityResourceAssignment[]` en `types.ts`. Corregir todos los factories en tests que crean `Activity` sin ese campo. + +**F.3 — Tests nuevos del sprint** +Tests unitarios del motor de simulación cubriendo los tres casos con `automatedAssignedResources`: solo fijo, solo recursos, mixto. Tests de `drawResourcesSection` para escenario automatizado. + +--- + +## Cambios autorizados + +### `src/domain/types.ts` + +Remover el `?` del campo: + +```typescript +// ANTES: +automatedAssignedResources?: ActivityResourceAssignment[] // 🆕 Sprint 3 + +// DESPUÉS: +automatedAssignedResources: ActivityResourceAssignment[] // requerido desde Etapa 6 +``` + +Después de este cambio, TypeScript reportará errores en los factories de tests. Corregir todos los que aparezcan (ver sección de tests). + +### `src/lib/export/pdf-sections.ts` + +**Agregar parámetro `scenario` a `drawResourcesSection`:** + +```typescript +export function drawResourcesSection( + doc: any, + autoTable: Function, + result: { perActivity: ActivitySimResult[] }, + resources: Resource[], + activities: Activity[], + currency: string, + startY: number, + scenario: 'actual' | 'automatizado' = 'actual' // 🆕 default 'actual' = retrocompatible +): void +``` + +**Corregir el lookup de asignación** (línea ~547 del archivo actual): + +```typescript +// ANTES (siempre usa AS-IS): +const assignment = activityRow?.assignedResources.find((a) => a.resourceId === br.resourceId) + +// DESPUÉS (usa el array del escenario correcto): +const assignment = scenario === 'automatizado' + ? activityRow?.automatedAssignedResources?.find((a) => a.resourceId === br.resourceId) + : activityRow?.assignedResources.find((a) => a.resourceId === br.resourceId) +``` + +**Nota:** `automatedAssignedResources` puede ser `undefined` en actividades antiguas migradas sin pasar por ImportPage (aunque la migración Dexie v4 debería poblarlas con `[]`). El optional chaining `?.find` ya cubre ese caso. + +El resto del cuerpo de `drawResourcesSection` no cambia. + +### `src/lib/export/pdf-export.ts` + +En `exportScenarioPdf`, actualizar la llamada a `drawResourcesSection` para pasar el `tab`: + +```typescript +// Antes: +drawResourcesSection(docAny, autoTable, result, resources, activities, currency, y) + +// Después: +drawResourcesSection(docAny, autoTable, result, resources, activities, currency, y, tab) +``` + +Esta es la única línea que cambia en este archivo. + +--- + +## Tests — correcciones de factories + +Cuando se promueva `automatedAssignedResources` a requerido, TypeScript reportará error en los siguientes archivos. Agregar `automatedAssignedResources: []` a cada factory que construye un `Activity`: + +### `tests/domain/simulation.test.ts` + +**Factory `makeAutoActivity`** (~línea 218): agregar campo: +```typescript +automatedCostFixed: opts.automatedCost ?? 0, +automatedTimeMinutes: opts.automatedTime ?? 0, +automatedAssignedResources: opts.automatedAssignedResources ?? [], // 🆕 +``` + +Si la interfaz de `opts` no incluye el campo, agregar: +```typescript +automatedAssignedResources?: ActivityResourceAssignment[] +``` + +**Factory genérico** (~línea 77): agregar `automatedAssignedResources: []`. + +**Factory `makeActivity` para recursos** (~línea 410): agregar `automatedAssignedResources: []`. + +### `tests/integration/indexeddb-persistence.test.ts` + +**`makeActivity`** (~línea 40): agregar `automatedAssignedResources: []` al objeto retornado. + +### `tests/integration/sprint1-report-roi.test.ts` + +**Factories inline** (~líneas 51, 311): agregar `automatedAssignedResources: []` a cada construcción de Activity. + +### `tests/lib/export/pdf-resources-section.test.ts` + +**`mockActivity`** (~línea 34): agregar `automatedAssignedResources: []`. + +### `tests/integration/dexie-migration.test.ts` + +**`makeLegacyActivity`** (~línea 19): usa `Omit`. Verificar si el tipo resultante cubre `automatedAssignedResources`. Si TypeScript reporta error, agregar `'automatedAssignedResources'` al Omit O cambiar la función para retornar directamente el campo con `[]`. + +--- + +## Tests — nuevos + +### `tests/domain/simulation-sprint3.test.ts` (NUEVO) + +Cubrir los tres casos de recursos en escenario automatizado. Usar el mismo BPMN lineal simple de un nodo que ya existe en otros tests del dominio. + +#### Caso 1: solo costo fijo, sin recursos TO-BE +``` +Actividad automatable, automatedCostFixed=100, automatedTimeMinutes=60, automatedAssignedResources=[] +→ scenario=automated: costo esperado = 100 * execProb (sin costo de recursos) +→ resourceCostBreakdown vacío +``` + +#### Caso 2: solo recursos TO-BE, sin costo fijo +``` +Actividad automatable, automatedCostFixed=0, automatedTimeMinutes=30 +automatedAssignedResources=[{ resourceId: 'r1', utilizationMinutes: 60, units: 2 }] +Resource r1: costPerHour=30 +→ scenario=automated: resourceCost = 30 * (60/60) * 2 * execProb = 60 * execProb +→ resourceCostBreakdown: [{ resourceId: 'r1', resourceName: 'Bot', cost: 60 * execProb }] +``` + +#### Caso 3: mixto — costo fijo + recurso TO-BE +``` +Actividad automatable, automatedCostFixed=50, automatedTimeMinutes=30 +automatedAssignedResources=[{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }] +Resource r1: costPerHour=60 +→ scenario=automated: totalCost = (50 + 60*(30/60)*1) * execProb = (50+30) * execProb = 80 * execProb +``` + +#### Caso 4: actividad NO automatable → usa assignedResources (regresión) +``` +Actividad automatable=false, con assignedResources=[r1] y automatedAssignedResources=[r2] +→ scenario=automated: usa r1 (assignedResources), NO r2 +``` + +**Nota de arquitectura:** para los tests de dominio puro, usar un grafo BPMN de un solo nodo secuencial (sin gateways), igual que en el `describe('scenario=automated')` ya existente en `simulation.test.ts`. Los inputs del motor (`activities`, `resources`, `gateways`, `graph`) se construyen inline. + +### `tests/lib/export/pdf-resources-section.test.ts` — tests nuevos (agregar al archivo existente) + +Agregar un `describe` nuevo: `drawResourcesSection — escenario automatizado`: + +#### Test 1: escenario automatizado usa automatedAssignedResources para min/unidades +``` +mockActivity con assignedResources=[{ utilMin: 30, units: 1 }] y automatedAssignedResources=[{ utilMin: 5, units: 3 }] +Llamar drawResourcesSection(..., scenario='automatizado') +→ autoTable debe recibir min='5' y units='3' (no '30' y '1') +``` + +#### Test 2: escenario actual usa assignedResources (regresión) +``` +Mismo mockActivity +Llamar drawResourcesSection(..., scenario='actual') [o sin el param] +→ autoTable recibe min='30' y units='1' +``` + +--- + +## ❌ Lo que NO entra en esta etapa + +- Tests E2E de la biblioteca (Playwright) — diferido a Etapa 7 o post-sprint si hay tiempo +- PDF de ROI — no modificar `pdf-sections-roi.ts` +- Cambios en `simulation.ts`, `ActivityPanel.tsx`, `ActivitiesTable.tsx` — ya cerrados en Etapa 5 +- Cambios en `WorkspacePage.tsx`, `BpmnCanvas.tsx`, stores — ya cerrados en Etapas anteriores +- Nuevas features de UI + +--- + +## Verificación antes de entregar + +```bash +# 1. Build limpio (verifica que el campo requerido no deja ningún any suelto) +npm run build + +# 2. Tests — el count debe subir respecto a 555 +npm run test + +# 3. Control de scope +git diff --name-only HEAD +``` + +El diff debe mostrar únicamente: +- `src/domain/types.ts` +- `src/lib/export/pdf-sections.ts` +- `src/lib/export/pdf-export.ts` +- `tests/domain/simulation.test.ts` +- `tests/domain/simulation-sprint3.test.ts` (nuevo) +- `tests/lib/export/pdf-resources-section.test.ts` +- `tests/integration/indexeddb-persistence.test.ts` +- `tests/integration/sprint1-report-roi.test.ts` +- `tests/integration/dexie-migration.test.ts` + +Si aparece cualquier archivo de `src/features/`, `src/store/`, o `src/persistence/`: **revertir y reportar**. + +### Verificación funcional mandatoria + +Antes de reportar, verificar manualmente: + +1. **PDF automatizado muestra min/unidades correctas:** asignar un recurso TO-BE con `utilizationMinutes=5, units=3` → exportar PDF desde el tab Automatizado → verificar que la tabla de recursos muestra `5` y `3` (no los valores del AS-IS). +2. **PDF actual no regresiona:** exportar PDF desde tab Actual → tabla de recursos muestra los valores de `assignedResources` sin cambio. +3. **TypeScript limpio:** `npm run build` sin errores — el campo promovido a requerido no deja `any` ni errores de tipo. + +--- + +## Criterios de aceptación + +- [ ] `automatedAssignedResources` requerido en `types.ts` sin errores TypeScript en build. +- [ ] PDF tab "Automatizado": tabla de recursos muestra `utilizationMinutes` y `units` de `automatedAssignedResources`. +- [ ] PDF tab "Actual": sin regresión, tabla sin cambio. +- [ ] Todos los tests previos siguen verdes (555+). +- [ ] 4 nuevos tests en `simulation-sprint3.test.ts` (casos 1–4) verdes. +- [ ] 2 nuevos tests en `pdf-resources-section.test.ts` verdes. +- [ ] Validación visual del director aprobada (PDF tab Automatizado con datos correctos). + +--- + +## Reporte esperado al terminar + +1. Resultado de `git diff --name-only HEAD`. +2. Resultado de `npm run build` y `npm run test` (incluir count final de tests). +3. Confirmación de los 3 casos de verificación funcional. +4. Lista de factories de tests corregidos (archivo + línea aproximada). +5. Cualquier decisión de iniciativa (Nivel 1/2/3). +6. Si algún archivo de test tuvo más cambios de los esperados: declararlo. + +--- + +## Referencias + +- `sprints/sprint-3/BRIEF.md` — sección 4, Feature F +- `src/lib/export/pdf-sections.ts` — `drawResourcesSection` (~línea 479), lookup de `assignedResources` (~línea 547) +- `src/lib/export/pdf-export.ts` — `exportScenarioPdf` (~línea 54), llamada a `drawResourcesSection` (~línea 147) +- `tests/domain/simulation.test.ts` — factories `makeAutoActivity` (~línea 218) y genérico (~línea 77) +- `tests/lib/export/pdf-resources-section.test.ts` — `mockActivity` fixture (~línea 34) +- `methodology/ANTI_PATTERNS.md` — AP.8 (adelanto silencioso), AP.9 (restricciones contradictorias) +- `methodology/PATTERNS.md` — C.7 (git diff como control preventivo) diff --git a/sprints/sprint-3/ETAPA_7_PROMPT.md b/sprints/sprint-3/ETAPA_7_PROMPT.md new file mode 100644 index 0000000..cd4c514 --- /dev/null +++ b/sprints/sprint-3/ETAPA_7_PROMPT.md @@ -0,0 +1,267 @@ +# Sprint 3 — Etapa 7: Polish — Identidad de marca en la web app + +## Contexto + +Sos Claude Code trabajando en **InQ ROI**. Las reglas de implementación están en `simulador-web/CLAUDE.md`. El brief del sprint está en `sprints/sprint-3/BRIEF.md`. + +**Estado al iniciar esta etapa:** +- Etapas 1–6 completadas. Sprint 3 funcionalmente completo. +- La web app no muestra en ningún lugar el nombre del producto "InQ ROI" ni el logo de InQuality. El `AppFooter` existe pero tiene texto muy tenue (`text-slate-300`) que pasa desapercibido. +- El favicon actual (`public/favicon.svg`) es un gráfico de barras azul genérico — no representa la marca InQ. +- Los logos en PNG están en `assets/` en la raíz del proyecto. Vite sirve estáticos desde `public/`. Los archivos tienen espacios en el nombre y necesitan ser copiados con nombres limpios. + +**Esta etapa es de branding puro. No hay lógica de negocio, no hay stores, no hay modelos.** + +--- + +## Objetivo + +Agregar identidad visual de InQ ROI en la web app: +1. **Nuevo componente `AppHeader`:** barra naranja horizontal con logo InQuality en blanco y nombre del producto "InQ ROI". Aparece en todas las páginas principales. +2. **Logos en `public/`:** copiar los PNGs relevantes con nombres sin espacios. +3. **Favicon actualizado:** isotipo InQ naranja en lugar del gráfico de barras azul genérico. + +--- + +## Paso 0 — Copiar logos a `public/` + +Antes de cualquier código, copiar los siguientes archivos desde `assets/` a `public/`: + +```bash +# Ejecutar desde simulador-web/ +cp "assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 BCO.png" "public/inquality-logo-white.png" +cp "assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png" "public/inquality-logo-color.png" +``` + +Estos son archivos binarios — copiarlos sin modificar. Los nombres de destino no tienen espacios para compatibilidad con Vite. + +--- + +## Cambios autorizados + +### `public/inquality-logo-white.png` (NUEVO — binario copiado) +### `public/inquality-logo-color.png` (NUEVO — binario copiado) + +### `public/favicon.svg` (MODIFICAR) + +Reemplazar el contenido actual con un favicon naranja InQ. Usar el isotipo simplificado — una "Q" con el naranja de marca en un fondo redondeado: + +```svg + + + + + Q + +``` + +### `src/components/AppHeader.tsx` (NUEVO) + +Componente de barra de marca. Diseño: fondo degradado `#F59845 → #ED3E8F` (gradiente de identidad InQ), logo blanco a la izquierda, texto "InQ ROI" a la derecha en blanco. Altura: 44px. + +```tsx +export function AppHeader() { + return ( +
+ {/* Logo InQuality blanco */} + InQuality + + {/* Nombre del producto */} + + InQ ROI + +
+ ) +} +``` + +**Notas de diseño:** +- El gradiente (`#F59845 → #ED3E8F`) es el gradiente de identidad definido en `CLAUDE.md`. No usar colores CONCILIA. +- El logo blanco (`/inquality-logo-white.png`) es adecuado para fondos oscuros/naranja. +- La altura 44px es la misma que la mayoría de los headers internos de la app para no generar desplazamiento visual brusco. + +### `src/features/library/LibraryPage.tsx` + +Agregar `` como primer elemento del render raíz: + +```tsx +import { AppHeader } from '@/components/AppHeader' + +// En el return del componente: +return ( +
+ {/* 🆕 */} +
+ {/* contenido existente sin cambio */} +
+
+) +``` + +### `src/features/workspace/WorkspacePage.tsx` + +Agregar `` antes del `
` interno de la workspace: + +```tsx +import { AppHeader } from '@/components/AppHeader' + +// En el return del componente (el div raíz flex-col): +return ( +
+ {/* 🆕 — encima del header del workspace */} +
+ {/* header existente sin cambio */} +
+ {/* resto sin cambio */} +
+) +``` + +### `src/features/report/ReportPage.tsx` + +Agregar `` al inicio del div raíz: + +```tsx +import { AppHeader } from '@/components/AppHeader' + +// En el return del componente: +return ( + +
+ {/* 🆕 */} + {/* banner stale y resto sin cambio */} +
+
+) +``` + +### `src/features/import/ImportPage.tsx` + +Agregar `` al inicio del div raíz de la página. + +### `src/features/settings/SettingsPage.tsx` + +Agregar `` al inicio del div raíz de la página. + +### `src/components/AppFooter.tsx` + +El footer ya tiene el texto correcto. Hacerlo levemente más visible: + +```tsx +// Cambiar text-slate-300 → text-slate-400 para mejor contraste +

+ InQ ROI · Powered by InQuality · v0.1.0 · Hecho desde Paraguay 🇵🇾 +

+``` + +--- + +## ❌ Lo que NO entra en esta etapa + +- Cambios en stores, simulación, persistencia, dominio +- Cambios en componentes de UI que no sean los listados arriba +- Nuevo routing o cambios en `router.tsx` +- Modificar el logo PNG o cambiar la identidad visual establecida +- Agregar links de navegación en el `AppHeader` (solo marca, sin nav) +- `pdf-sections.ts` — el header del PDF ya tiene logo (implementado en sprints anteriores) + +--- + +## Verificación antes de entregar + +```bash +# 1. Build limpio +npm run build + +# 2. Tests sin regresión +npm run test + +# 3. Control de scope +git diff --name-only HEAD +``` + +El diff debe mostrar únicamente: +- `public/favicon.svg` +- `public/inquality-logo-white.png` (binario nuevo) +- `public/inquality-logo-color.png` (binario nuevo) +- `src/components/AppHeader.tsx` (nuevo) +- `src/features/library/LibraryPage.tsx` +- `src/features/workspace/WorkspacePage.tsx` +- `src/features/report/ReportPage.tsx` +- `src/features/import/ImportPage.tsx` +- `src/features/settings/SettingsPage.tsx` +- `src/components/AppFooter.tsx` + +Si aparece cualquier archivo de `src/store/`, `src/domain/`, `src/persistence/`, o `src/lib/export/`: **revertir y reportar**. + +### Verificación funcional mandatoria + +Antes de reportar, verificar manualmente: + +1. **Logo visible en biblioteca:** abrir la home → ver barra naranja/magenta con logo blanco de InQuality y "InQ ROI" a la derecha. +2. **Logo en workspace:** abrir un proceso → la barra aparece encima del header del proceso. +3. **Logo en reporte:** navegar al reporte → la barra está arriba de todo, incluso por encima del banner stale si existe. +4. **Logo en import:** ir a importar BPMN → la barra está presente. +5. **Logo en settings:** ir a configuración → la barra está presente. +6. **Favicon:** en el tab del browser se ve el fondo naranja con "Q", no el gráfico de barras azul. +7. **Sin regresión de layout:** el workspace no tiene scroll horizontal inesperado por la nueva barra. + +--- + +## Criterios de aceptación + +- [ ] Logo InQuality visible en las 5 páginas principales. +- [ ] Favicon actualizado a isotipo naranja InQ. +- [ ] Gradiente `#F59845 → #ED3E8F` correcto — no colores CONCILIA. +- [ ] Build sin errores TypeScript. +- [ ] Todos los tests previos siguen verdes (561). +- [ ] Validación visual del director aprobada — OK formal requerido antes de cerrar. + +--- + +## Reporte esperado al terminar + +1. Resultado de `git diff --name-only HEAD`. +2. Resultado de `npm run build` y `npm run test`. +3. Confirmación de los 7 casos de verificación funcional. +4. Cualquier decisión de iniciativa tomada (Nivel 1/2/3). + +--- + +## Referencias + +- `CLAUDE.md` — reglas de marca: gradiente `#F59845 → #ED3E8F`, no usar colores CONCILIA +- `assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 BCO.png` — logo horizontal blanco (para fondo naranja) +- `assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png` — logo horizontal color (para fondo blanco) +- `src/components/AppFooter.tsx` — patrón de componente de marca existente +- `methodology/PATTERNS.md` — C.7 (git diff como control preventivo) diff --git a/src/components/AppFooter.tsx b/src/components/AppFooter.tsx index 261f76f..04cb505 100644 --- a/src/components/AppFooter.tsx +++ b/src/components/AppFooter.tsx @@ -1,7 +1,7 @@ export function AppFooter() { return (
-

+

InQ ROI · Powered by InQuality · v0.1.0 · Hecho desde Paraguay 🇵🇾

diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx new file mode 100644 index 0000000..f773e5d --- /dev/null +++ b/src/components/AppHeader.tsx @@ -0,0 +1,39 @@ +import { Link } from '@tanstack/react-router' + +export function AppHeader() { + return ( +
+ + InQuality + + + InQ ROI + +
+ ) +} diff --git a/src/domain/simulation.ts b/src/domain/simulation.ts index 277e90a..d4ef363 100644 --- a/src/domain/simulation.ts +++ b/src/domain/simulation.ts @@ -204,7 +204,11 @@ export function runSimulation(input: SimulationInput): SimulationResult { const resourceBreakdown: ActivitySimResult['resourceCostBreakdown'] = [] let resourceCostDirect = 0 - for (const assignment of activity.assignedResources) { + const resourceAssignments = useAutomated + ? (activity.automatedAssignedResources ?? []) + : activity.assignedResources + + for (const assignment of resourceAssignments) { const resource = resources.get(assignment.resourceId) if (!resource) continue const hoursUsed = (assignment.utilizationMinutes / 60) * assignment.units diff --git a/src/domain/types.ts b/src/domain/types.ts index 13d1e1e..a3a05bd 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -16,6 +16,8 @@ export interface Process { automationInvestment: number // 🆕 Sprint 1 — inversión total del proyecto, default 0 createdAt: number updatedAt: number + groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar") + tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda } export type ActivityType = 'task' | 'subprocess' @@ -32,6 +34,7 @@ export interface Activity { automatable: boolean // 🆕 Sprint 1 — si la actividad es candidata a automatización automatedCostFixed: number // 🆕 Sprint 1 — costo recurrente por ejecución automatizada automatedTimeMinutes: number // 🆕 Sprint 1 — tiempo de la actividad en escenario automatizado + automatedAssignedResources: ActivityResourceAssignment[] // 🆕 Sprint 3 — recursos del escenario automatizado (requerido desde Etapa 6) } export interface ActivityResourceAssignment { @@ -74,6 +77,20 @@ export interface Simulation { resultAutomated?: SimulationResult // 🆕 Sprint 1 — escenario automatizado (opcional por retrocompatibilidad) } +// ─── Process Library ───────────────────────────────────────────────────────── + +export interface ProcessGroup { + id: UUID // generado con crypto.randomUUID() + name: string + createdAt: number // timestamp Unix ms + updatedAt: number // timestamp Unix ms +} + +export interface AppSettings { + id: 'singleton' // siempre un único registro por instalación + groupLabel: string // etiqueta configurable para el nivel de agrupación. Default: 'Cliente' +} + // ─── Motor de simulación: Input/Output ─────────────────────────────────────── // Escenario de simulación: actual (campos directCostFixed/executionTimeMinutes) diff --git a/src/features/import/ImportPage.tsx b/src/features/import/ImportPage.tsx index c6d3226..b22cc2a 100644 --- a/src/features/import/ImportPage.tsx +++ b/src/features/import/ImportPage.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from '@tanstack/react-router' +import { useNavigate, useSearch } from '@tanstack/react-router' import { useCallback, useState } from 'react' import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react' import { v4 as uuidv4 } from 'uuid' @@ -9,6 +9,7 @@ import type { Process, Activity, GatewayConfig } from '@/domain/types' import { bpmnTypeToGatewayType } from '@/domain/types' import { useToast } from '@/components/ui/use-toast' import { AppFooter } from '@/components/AppFooter' +import { AppHeader } from '@/components/AppHeader' const SAMPLE_PROCESSES = [ { @@ -38,6 +39,10 @@ export function ImportPage() { const [isProcessing, setIsProcessing] = useState(false) const [loadingSampleId, setLoadingSampleId] = useState(null) + // groupId viene de la URL cuando el usuario importa desde dentro de un grupo + const search = useSearch({ from: '/import' }) + const targetGroupId = (search as { groupId?: string }).groupId ?? null + const processFile = useCallback(async (file: File) => { if (!file.name.endsWith('.bpmn') && !file.name.endsWith('.xml')) { toast({ @@ -69,6 +74,8 @@ export function ImportPage() { automationInvestment: 0, createdAt: now, updatedAt: now, + groupId: targetGroupId, + tags: [], } const activityElements = extractActivityElements(graph) @@ -84,6 +91,7 @@ export function ImportPage() { automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatedAssignedResources: [], })) const gatewayElements = extractGatewayElements(graph) @@ -167,7 +175,9 @@ export function ImportPage() { } return ( -
+
+ +
{/* Header */}
@@ -278,6 +288,7 @@ export function ImportPage() {

+
) } diff --git a/src/features/library/LibraryPage.tsx b/src/features/library/LibraryPage.tsx new file mode 100644 index 0000000..908b730 --- /dev/null +++ b/src/features/library/LibraryPage.tsx @@ -0,0 +1,621 @@ +import { useEffect, useState, useRef, useCallback } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { + Upload, Search, Plus, Building2, FolderX, GitBranch, + Clock, TrendingUp, BarChart2, FileX2, X, Settings, +} from 'lucide-react' +import { useLibraryStore } from '@/store/library-store' +import { formatCurrency } from '@/lib/format' +import type { Process, ProcessGroup, Simulation } from '@/domain/types' +import { AppHeader } from '@/components/AppHeader' + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function relativeDate(ts: number): string { + const days = Math.floor((Date.now() - ts) / 86_400_000) + if (days === 0) return 'hoy' + if (days === 1) return 'hace 1 día' + return `hace ${days} días` +} + +function tagCloudData(processes: Process[]): Array<{ tag: string; count: number; fontSize: string }> { + const freq = new Map() + processes.forEach((p) => p.tags.forEach((t) => freq.set(t, (freq.get(t) ?? 0) + 1))) + const sorted = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12) + if (sorted.length === 0) return [] + const maxCount = sorted[0][1] + const minCount = sorted[sorted.length - 1][1] + const range = maxCount - minCount || 1 + return sorted.map(([tag, count]) => { + const t = (count - minCount) / range + return { tag, count, fontSize: `${Math.round(10 + t * 3)}px` } + }) +} + +function matchesSearch(p: Process, q: string): boolean { + const lower = q.toLowerCase() + return p.name.toLowerCase().includes(lower) || p.tags.some((t) => t.includes(lower)) +} + +function matchesTags(p: Process, activeTags: string[]): boolean { + return activeTags.every((t) => p.tags.includes(t)) +} + +// ─── KPI Badge ──────────────────────────────────────────────────────────────── + +function KpiBadge({ process, simulation }: { process: Process; simulation: Simulation | null | undefined }) { + if (simulation === undefined) return null // cargando + + if (!simulation) { + return Sin simular + } + + if (simulation.resultAutomated) { + const ahorro = (simulation.result.totalCost - simulation.resultAutomated.totalCost) * process.annualFrequency + return ( +
+
+ + {formatCurrency(ahorro, process.currency)} +
+

Ahorro anual proyectado

+
+ ) + } + + const costoAnual = simulation.result.totalCost * process.annualFrequency + return ( +
+
+ + {formatCurrency(costoAnual, process.currency)} +
+

Costo anual actual

+
+ ) +} + +// ─── Process Card ───────────────────────────────────────────────────────────── + +function ProcessCard({ process, simulation, showGroupChip, groupName }: { + process: Process + simulation: Simulation | null | undefined + showGroupChip?: boolean + groupName?: string +}) { + const navigate = useNavigate() + + return ( +
navigate({ to: '/workspace/$processId', params: { processId: process.id } })} + > + {/* Ícono */} +
+ +
+ + {/* Info central */} +
+

{process.name || 'Sin nombre'}

+
+ + Modificado {relativeDate(process.updatedAt)} +
+ {(process.tags.length > 0 || showGroupChip) && ( +
+ {showGroupChip && groupName && ( + + {groupName} + + )} + {process.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ + {/* KPI derecha */} +
+ +
+
+ ) +} + +// ─── Group Card ─────────────────────────────────────────────────────────────── + +function GroupCard({ group, processCount, lastUpdated, onClick }: { + group: ProcessGroup + processCount: number + lastUpdated: number | null + onClick: () => void +}) { + return ( + + ) +} + +// ─── New Group Card ─────────────────────────────────────────────────────────── + +function NewGroupCard({ label, onCreate }: { label: string; onCreate: (name: string) => void }) { + const [editing, setEditing] = useState(false) + const [value, setValue] = useState('') + const inputRef = useRef(null) + + const confirm = useCallback(() => { + const trimmed = value.trim() + if (trimmed) onCreate(trimmed) + setValue('') + setEditing(false) + }, [value, onCreate]) + + const cancel = useCallback(() => { + setValue('') + setEditing(false) + }, []) + + useEffect(() => { + if (editing) inputRef.current?.focus() + }, [editing]) + + if (editing) { + return ( +
+ setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') confirm() + if (e.key === 'Escape') cancel() + }} + placeholder={`Nombre del ${label.toLowerCase()}...`} + className="w-full text-[12px] bg-transparent border-none outline-none placeholder:text-muted-foreground" + /> +
+ + +
+
+ ) + } + + return ( + + ) +} + +// ─── Home View ──────────────────────────────────────────────────────────────── + +function HomeView({ + onNavigateToGroup, + onImport, +}: { + onNavigateToGroup: (groupId: string | null) => void + onImport: (groupId?: string) => void +}) { + const { groups, processes, settings, createGroup } = useLibraryStore() + const [search, setSearch] = useState('') + const [activeTags, setActiveTags] = useState([]) + + const cloud = tagCloudData(processes) + const unclassifiedProcesses = processes.filter((p) => p.groupId == null) + + const isSearching = search.trim().length > 0 || activeTags.length > 0 + + const filteredProcesses = isSearching + ? processes.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags)) + : [] + + function toggleTag(tag: string) { + setActiveTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]) + } + + // Empty state: sin grupos ni procesos + if (groups.length === 0 && processes.length === 0) { + return ( +
+ +

Tu biblioteca está vacía

+

Importá tu primer BPMN para empezar

+ +
+ ) + } + + return ( +
+ {/* Búsqueda */} +
+ + setSearch(e.target.value)} + placeholder="Buscar proceso por nombre..." + className="w-full pl-9 pr-4 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+ + {/* Resultados de búsqueda */} + {isSearching && ( +
+ {/* Filtros activos */} + {activeTags.length > 0 && ( +
+ Filtros activos: + {activeTags.map((tag) => ( + + ))} + +
+ )} +

+ {filteredProcesses.length} resultado{filteredProcesses.length !== 1 ? 's' : ''} +

+ {filteredProcesses.length === 0 && ( +

Sin resultados para esa búsqueda.

+ )} + {filteredProcesses.map((p) => { + const group = groups.find((g) => g.id === p.groupId) + return ( + + ) + })} +
+ )} + + {/* Vista normal — grupos */} + {!isSearching && ( + <> + {/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */} + {cloud.length >= 3 && ( +
+ Tags: + {cloud.map(({ tag, fontSize }) => ( + + ))} +
+ )} + {/* Tags simples cuando hay 1-2 */} + {cloud.length > 0 && cloud.length < 3 && ( +
+ Tags: + {cloud.map(({ tag }) => ( + + ))} +
+ )} + + {/* Header sección grupos */} +
+ + {settings.groupLabel}s + + + {groups.length} + +
+ + {/* Grid de grupos */} +
+ {groups.map((group) => { + const groupProcesses = processes.filter((p) => p.groupId === group.id) + const lastUpdated = groupProcesses.length > 0 + ? Math.max(...groupProcesses.map((p) => p.updatedAt)) + : null + return ( + onNavigateToGroup(group.id)} + /> + ) + })} + createGroup(name)} + /> +
+ + {/* Sin clasificar */} + {unclassifiedProcesses.length > 0 && ( + + )} + + )} +
+ ) +} + +// ─── ProcessCardWithSim (carga simulación lazy) ─────────────────────────────── + +function ProcessCardWithSim({ process, showGroupChip, groupName }: { + process: Process + showGroupChip?: boolean + groupName?: string +}) { + const { getLatestSimulation } = useLibraryStore() + const [simulation, setSimulation] = useState(undefined) + + useEffect(() => { + let cancelled = false + getLatestSimulation(process.id).then((sim) => { + if (!cancelled) setSimulation(sim ?? null) + }) + return () => { cancelled = true } + }, [process.id, getLatestSimulation]) + + return +} + +// ─── Group View ─────────────────────────────────────────────────────────────── + +function GroupView({ + groupId, + onBack, +}: { + groupId: string | null + onBack: () => void +}) { + const { groups, processes, settings } = useLibraryStore() + const [search, setSearch] = useState('') + + const group = groupId ? groups.find((g) => g.id === groupId) : null + const groupName = group?.name ?? `Procesos sin ${settings.groupLabel.toLowerCase()}` + const groupProcesses = processes + .filter((p) => groupId === null ? p.groupId == null : p.groupId === groupId) + .filter((p) => matchesSearch(p, search)) + + return ( +
+ {/* Breadcrumb */} +
+ + / + {groupName} +
+

+ {groupProcesses.length} proceso{groupProcesses.length !== 1 ? 's' : ''} +

+ + {/* Búsqueda */} +
+ + setSearch(e.target.value)} + placeholder={`Buscar en este ${settings.groupLabel.toLowerCase()}...`} + className="w-full pl-9 pr-4 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+ + {/* Empty state */} + {groupProcesses.length === 0 && !search && ( +
+ +

+ No hay procesos en este {settings.groupLabel.toLowerCase()}. +

+

Importá un BPMN para comenzar.

+
+ )} + + {/* Lista de procesos */} +
+ {groupProcesses.map((p) => ( + + ))} +
+
+ ) +} + +// ─── LibraryPage ────────────────────────────────────────────────────────────── + +export function LibraryPage() { + const navigate = useNavigate() + const { load, isLoading, processes, groups, settings } = useLibraryStore() + const [view, setView] = useState<'home' | 'group'>('home') + const [activeGroupId, setActiveGroupId] = useState(null) + const [transitioning, setTransitioning] = useState(false) + + useEffect(() => { + load() + }, [load]) + + function navigateToGroup(groupId: string | null) { + setTransitioning(true) + setTimeout(() => { + setActiveGroupId(groupId) + setView('group') + setTransitioning(false) + }, 150) + } + + function navigateHome() { + setTransitioning(true) + setTimeout(() => { + setView('home') + setActiveGroupId(null) + setTransitioning(false) + }, 150) + } + + function handleImport(groupId?: string) { + if (groupId) { + navigate({ to: '/import', search: { groupId } }) + } else { + navigate({ to: '/import' }) + } + } + + // Preflight fix: contar grupos reales en lugar del clientName heredado + const totalGroups = groups.length + + return ( +
+ +
+ {/* Header */} +
+
+

Biblioteca de procesos

+

+ {processes.length} proceso{processes.length !== 1 ? 's' : ''} · {totalGroups} {settings.groupLabel.toLowerCase()}{totalGroups !== 1 ? 's' : ''} +

+
+
+ + +
+
+ + {/* Contenido con transición */} + {isLoading ? ( +
+
+
+ ) : ( +
+ {view === 'home' ? ( + + ) : ( + + )} +
+ )} +
+
+ ) +} diff --git a/src/features/report/ActivitiesTable.tsx b/src/features/report/ActivitiesTable.tsx index 7fd2263..3d583eb 100644 --- a/src/features/report/ActivitiesTable.tsx +++ b/src/features/report/ActivitiesTable.tsx @@ -10,6 +10,7 @@ type SortKey = 'activityName' | 'expectedDirectCost' | 'expectedIndirectCost' | interface ActivitiesTableProps { perActivity: ActivitySimResult[] + pairedBreakdown?: Record> mode: HeatmapMode currency: string highlightedId: string | null @@ -23,7 +24,7 @@ function SortIcon({ column, sortKey, dir }: { column: SortKey; sortKey: SortKey; : } -export function ActivitiesTable({ perActivity, mode, currency, highlightedId, onRowClick }: ActivitiesTableProps) { +export function ActivitiesTable({ perActivity, pairedBreakdown, mode, currency, highlightedId, onRowClick }: ActivitiesTableProps) { const [sortKey, setSortKey] = useState('expectedTotalCost') const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') const [expandedRows, setExpandedRows] = useState>(new Set()) @@ -167,23 +168,50 @@ export function ActivitiesTable({ perActivity, mode, currency, highlightedId, on
- {fixedCostExpected > 0 && ( -
- Fijo (insumos, licencias) - {formatCurrency(fixedCostExpected, currency)} -
- )} - {act.resourceCostBreakdown.map((r) => ( -
- {r.resourceName} - {formatCurrency(r.cost, currency)} -
- ))} - {act.resourceCostBreakdown.length === 0 && fixedCostExpected === 0 && ( -

Sin desglose disponible

- )} - {act.resourceCostBreakdown.length === 0 && fixedCostExpected > 0 && ( -

Sin recursos asignados — costo es 100% fijo

+ {pairedBreakdown?.[act.activityId] ? ( + <> +

AS-IS

+ {pairedBreakdown[act.activityId].map((r) => ( +
+ {r.resourceName} + {formatCurrency(r.cost, currency)} +
+ ))} + {pairedBreakdown[act.activityId].length === 0 && ( +

Sin recursos AS-IS

+ )} +

TO-BE

+ {act.resourceCostBreakdown.length === 0 + ?

Sin recursos TO-BE

+ : act.resourceCostBreakdown.map((r) => ( +
+ {r.resourceName} + {formatCurrency(r.cost, currency)} +
+ )) + } + + ) : ( + <> + {fixedCostExpected > 0 && ( +
+ Fijo (insumos, licencias) + {formatCurrency(fixedCostExpected, currency)} +
+ )} + {act.resourceCostBreakdown.map((r) => ( +
+ {r.resourceName} + {formatCurrency(r.cost, currency)} +
+ ))} + {act.resourceCostBreakdown.length === 0 && fixedCostExpected === 0 && ( +

Sin desglose disponible

+ )} + {act.resourceCostBreakdown.length === 0 && fixedCostExpected > 0 && ( +

Sin recursos asignados — costo es 100% fijo

+ )} + )}
diff --git a/src/features/report/ReportPage.tsx b/src/features/report/ReportPage.tsx index 78ce900..8422653 100644 --- a/src/features/report/ReportPage.tsx +++ b/src/features/report/ReportPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useParams, useNavigate } from '@tanstack/react-router' import { AlertTriangle, Home, Info } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -27,6 +27,8 @@ import { TransparencySection } from './TransparencySection' import { AutomationScenarioNote } from './AutomationScenarioNote' import { MethodologyFooter } from './MethodologyFooter' import type { HeatmapMode } from '@/lib/colors' +import { useSimulationStore } from '@/store/simulation-store' +import { AppHeader } from '@/components/AppHeader' export function ReportPage() { const { processId } = useParams({ from: '/report/$processId' }) @@ -35,6 +37,12 @@ export function ReportPage() { const { toast } = useToast() const [heatmapMode, setHeatmapMode] = useState('relative') + const { lastPersistedExecutedAt, loadLatestForProcess } = useSimulationStore() + + // Cargar executedAt persistido si el usuario llega directo al reporte (sin pasar por workspace) + useEffect(() => { + if (process) loadLatestForProcess(process.id) + }, [process?.id, loadLatestForProcess]) // Set de bpmnElementIds marcados como automatizables — debe estar antes de los early returns const automatableIds = useMemo( @@ -56,6 +64,14 @@ export function ReportPage() { }) }, [simulation, process]) + // Breakdown del escenario actual, indexado por activityId, para comparación lado a lado en tab Automatizado + const actualBreakdownById = useMemo(() => { + if (!simulation?.result) return undefined + return Object.fromEntries( + simulation.result.perActivity.map((a) => [a.activityId, a.resourceCostBreakdown]) + ) + }, [simulation?.result]) + // Estado y refs para el tab "Actual" const [selectedIdActual, setSelectedIdActual] = useState(null) const heatmapRef = useRef(null) @@ -219,9 +235,29 @@ export function ReportPage() { // marcadas como automatizables. Sin automatizables, los tabs muestran datos idénticos al actual. const hasAutomatedScenario = hasAutomatedResult && automatableIds.size > 0 + const isStale = Boolean(process && process.updatedAt > (lastPersistedExecutedAt ?? 0)) + return (
+ + {/* Banner stale — cambios sin simular */} + {isStale && ( +
+
+ + Los resultados pueden estar desactualizados — hay cambios sin simular. +
+ +
+ )} +
{/* ── Header (fuera de los tabs: aplica a todos los escenarios) ──── */} @@ -449,6 +485,7 @@ export function ReportPage() {
0 ? 'text-emerald-700' @@ -117,10 +119,10 @@ export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) { } iconColor="bg-amber-50" - valueColor={roi.roiAnnualPercent > 0 ? 'text-emerald-700' : roi.roiAnnualPercent < 0 ? 'text-red-600' : 'text-slate-900'} + valueColor={roiIsUndefined ? 'text-slate-400' : roi.roiAnnualPercent > 0 ? 'text-emerald-700' : roi.roiAnnualPercent < 0 ? 'text-red-600' : 'text-slate-900'} />
diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx new file mode 100644 index 0000000..98011dd --- /dev/null +++ b/src/features/settings/SettingsPage.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState, useRef } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { useLibraryStore } from '@/store/library-store' +import { AppHeader } from '@/components/AppHeader' + +export function SettingsPage() { + const navigate = useNavigate() + const { settings, updateSettings, load } = useLibraryStore() + const [localLabel, setLocalLabel] = useState(settings.groupLabel) + const [isDirty, setIsDirty] = useState(false) + + // Cargar settings si el store no fue inicializado (navegación directa a /settings) + useEffect(() => { load() }, [load]) + + // Sincronizar con el store solo cuando el usuario no está editando + useEffect(() => { + if (!isDirty) setLocalLabel(settings.groupLabel) + }, [settings.groupLabel, isDirty]) + + async function save() { + const trimmed = localLabel.trim() || 'Cliente' + setLocalLabel(trimmed) + setIsDirty(false) + await updateSettings({ groupLabel: trimmed }) + } + + const inputRef = useRef(null) + + return ( +
+ +
+ {/* Header */} +
+ +

Configuración

+

Personalización del workspace

+
+ + {/* Sección Biblioteca */} +
+
+

+ Biblioteca +

+
+ +
+ +

+ Cómo se llama el nivel de agrupación en la biblioteca. Por defecto: 'Cliente'. +

+ { setLocalLabel(e.target.value); setIsDirty(true) }} + onBlur={save} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); inputRef.current?.blur() } + if (e.key === 'Escape') { setLocalLabel(settings.groupLabel); setIsDirty(false); inputRef.current?.blur() } + }} + className="text-sm px-3 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-1 focus:ring-primary w-full max-w-xs" + placeholder="Cliente" + /> + {localLabel.trim() && ( +

+ Los procesos se organizarán por {localLabel.trim()}. +

+ )} +
+
+
+
+ ) +} diff --git a/src/features/workspace/ActivityPanel.tsx b/src/features/workspace/ActivityPanel.tsx index f27ee37..bcd20fc 100644 --- a/src/features/workspace/ActivityPanel.tsx +++ b/src/features/workspace/ActivityPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { PlusCircle, Trash2, Info, AlertTriangle } from 'lucide-react' +import { PlusCircle, Trash2, Info, AlertTriangle, X } from 'lucide-react' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' @@ -26,7 +26,11 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) { // Sincronizar con el store cuando cambia la selección useEffect(() => { - setLocalActivity(activity ? { ...activity, assignedResources: [...activity.assignedResources] } : null) + setLocalActivity(activity ? { + ...activity, + assignedResources: [...activity.assignedResources], + automatedAssignedResources: [...(activity.automatedAssignedResources ?? [])], + } : null) setIsDirty(false) }, [selectedElementId, activities]) @@ -84,6 +88,36 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) { ) } + // ── Handlers TO-BE ──────────────────────────────────────────────────────── + + function addAutomatedResource(resourceId: string) { + if (!localActivity) return + const already = (localActivity.automatedAssignedResources ?? []).some((r) => r.resourceId === resourceId) + if (already) return + handleChange('automatedAssignedResources', [ + ...(localActivity.automatedAssignedResources ?? []), + { resourceId, utilizationMinutes: 60, units: 1 }, + ]) + } + + function removeAutomatedResource(resourceId: string) { + if (!localActivity) return + handleChange( + 'automatedAssignedResources', + (localActivity.automatedAssignedResources ?? []).filter((r) => r.resourceId !== resourceId) + ) + } + + function updateAutomatedAssignment(resourceId: string, field: 'utilizationMinutes' | 'units', value: number) { + if (!localActivity) return + handleChange( + 'automatedAssignedResources', + (localActivity.automatedAssignedResources ?? []).map((r) => + r.resourceId === resourceId ? { ...r, [field]: value } : r + ) + ) + } + if (!selectedElementId) { return (
@@ -119,6 +153,12 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) { const localTotalCost = localActivity.directCostFixed + localResourceCost + const availableAutomatedResources = resources.filter( + (r) => !(localActivity.automatedAssignedResources ?? []).some((ar) => ar.resourceId === r.id) + ) + + const currency = currentProcess?.currency ?? 'USD' + return (
@@ -370,7 +410,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
{/* Campos — aparecen con transición suave cuando toggle=ON */} -
+
{/* Costo automatizado */}
@@ -417,6 +457,101 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) { />
+ {/* ── Recursos TO-BE ───────────────────────────────────────── */} + + +
+
+

+ Recursos TO-BE +

+ {availableAutomatedResources.length > 0 && ( + + )} +
+ + {(localActivity.automatedAssignedResources ?? []).length === 0 ? ( +

+ Sin recursos TO-BE — solo costo fijo automatizado. +

+ ) : ( +
+ {(localActivity.automatedAssignedResources ?? []).map((assignment) => { + const resource = resources.find((r) => r.id === assignment.resourceId) + if (!resource) return null + const cost = resource.costPerHour * (assignment.utilizationMinutes / 60) * assignment.units + return ( +
+
+ {resource.name} +
+ + {formatCurrency(cost, currency)} + + +
+
+
+
+

Min. por ejecución

+ + updateAutomatedAssignment( + assignment.resourceId, + 'utilizationMinutes', + Math.max(0, parseFloat(e.target.value) || 0) + ) + } + className="h-6 text-xs font-mono" + /> +
+
+

Unidades

+ + updateAutomatedAssignment( + assignment.resourceId, + 'units', + Math.max(1, parseInt(e.target.value) || 1) + ) + } + className="h-6 text-xs font-mono" + /> +
+
+
+ ) + })} +
+ )} +
+ {/* Helper ámbar — siempre en el DOM para que la transición funcione al tipear un valor (no desaparece instantáneo sino con fade). La condición controla opacity + max-h, no mount/unmount. */} @@ -424,6 +559,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) { const showHelper = localActivity.automatable && localActivity.automatedCostFixed === 0 && localActivity.automatedTimeMinutes === 0 + && (localActivity.automatedAssignedResources ?? []).length === 0 return (
+ return ( +
+
+ +
+ ) } diff --git a/src/features/workspace/WorkspacePage.tsx b/src/features/workspace/WorkspacePage.tsx index d240375..070b71d 100644 --- a/src/features/workspace/WorkspacePage.tsx +++ b/src/features/workspace/WorkspacePage.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, useCallback, useMemo, useDeferredValue } from 'react' import { useParams, useNavigate } from '@tanstack/react-router' import { - Loader2, BarChart3, Play, AlertTriangle, + Loader2, BarChart3, Play, AlertTriangle, AlertCircle, PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2, Check, Pencil } from 'lucide-react' @@ -18,6 +18,8 @@ import { GlobalSettingsPanel } from './GlobalSettingsPanel' import { useProcessStore } from '@/store/process-store' import { useSimulationStore } from '@/store/simulation-store' import { useSimulate } from '../simulation/useSimulate' +import { useLibraryStore } from '@/store/library-store' +import { AppHeader } from '@/components/AppHeader' type SelectedCategory = 'activity' | 'gateway' | null @@ -25,7 +27,7 @@ export function WorkspacePage() { const { processId } = useParams({ from: '/workspace/$processId' }) const navigate = useNavigate() const { currentProcess, activities, isLoading, error, loadProcess, gateways, updateProcess } = useProcessStore() - const { isRunning, resultActual, error: simError, selectActivity } = useSimulationStore() + const { isRunning, resultActual, error: simError, selectActivity, loadLatestForProcess, lastPersistedExecutedAt } = useSimulationStore() const { simulate } = useSimulate() const { toast } = useToast() @@ -41,6 +43,11 @@ export function WorkspacePage() { const [savedField, setSavedField] = useState<'name' | 'client' | null>(null) const savedTimerRef = useRef | null>(null) + // Tags chip input + const [tagInput, setTagInput] = useState('') + const [showTagSuggestions, setShowTagSuggestions] = useState(false) + const getAllTags = useLibraryStore((state) => state.getAllTags) + // Usamos un ref para saber si loadProcess ya terminó al menos una vez. // Esto evita que el redirect se dispare en el render inicial donde // isLoading=false y currentProcess=null (antes del primer efecto). @@ -48,6 +55,11 @@ export function WorkspacePage() { useEffect(() => { loadProcess(processId) }, [processId, loadProcess]) + // Cargar el último executedAt persistido para comparar contra updatedAt (indicador stale) + useEffect(() => { + if (currentProcess) loadLatestForProcess(currentProcess.id) + }, [currentProcess?.id, loadLatestForProcess]) + useEffect(() => { if (isLoading) { hasLoadedOnce.current = true; return } if (!hasLoadedOnce.current) return // carga no iniciada aún @@ -71,6 +83,12 @@ export function WorkspacePage() { const canSimulate = !isRunning && xorValidation.valid + // Hay cambios sin simular cuando updatedAt supera al executedAt de la última simulación persistida + const isStale = Boolean( + currentProcess && + currentProcess.updatedAt > (lastPersistedExecutedAt ?? 0) + ) + // IDs de actividades marcadas como automatizables — alimenta los overlays del canvas. // useDeferredValue evita re-renders en cascada mientras el usuario edita el panel. const automatableElementIds = useMemo( @@ -118,6 +136,26 @@ export function WorkspacePage() { savedTimerRef.current = setTimeout(() => setSavedField(null), 1500) } + function addTag(raw: string) { + const normalized = raw.trim().toLowerCase() + if (!normalized) return + const current = currentProcess?.tags ?? [] + if (current.some((t) => t === normalized)) return + updateProcess({ tags: [...current, normalized] }) + setTagInput('') + setShowTagSuggestions(false) + } + + function removeTag(tag: string) { + const current = currentProcess?.tags ?? [] + updateProcess({ tags: current.filter((t) => t !== tag) }) + } + + function handleTagKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { e.preventDefault(); if (tagInput.trim()) addTag(tagInput) } + if (e.key === 'Escape') { setTagInput(''); setShowTagSuggestions(false) } + } + async function handleSimulate() { if (!canSimulate) return const simResult = await simulate() @@ -147,9 +185,10 @@ export function WorkspacePage() { return (
+ {/* ── Topbar ─────────────────────────────────────────────────── */} -
+
)} + {/* Tags — chips removibles + input inline */} + {(() => { + const processTags = currentProcess.tags ?? [] + const suggestions = getAllTags() + .filter((t) => t.includes(tagInput.toLowerCase()) && !processTags.includes(t)) + .slice(0, 6) + return ( +
+ {processTags.map((tag) => ( + + {tag} + + + ))} +
+ { setTagInput(e.target.value); setShowTagSuggestions(true) }} + onKeyDown={handleTagKeyDown} + onFocus={() => setShowTagSuggestions(true)} + onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)} + placeholder="Agregar tag..." + className="text-xs bg-transparent border-b border-muted outline-none w-24 placeholder:text-muted-foreground/50" + /> + {showTagSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )} +
+
+ ) + })()} +
{/* Errores de simulación */} @@ -261,6 +353,15 @@ export function WorkspacePage() { )} + {/* Indicador stale */} + {isStale && ( +
+ + Hay cambios sin simular +
+ )} + {/* Simular */} diff --git a/src/lib/export/pdf-export.ts b/src/lib/export/pdf-export.ts index 338a76a..2cf0258 100644 --- a/src/lib/export/pdf-export.ts +++ b/src/lib/export/pdf-export.ts @@ -144,7 +144,7 @@ async function exportScenarioPdf( if (hasResources) { docAny.addPage('a4', 'portrait') y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 4 }) - drawResourcesSection(docAny, autoTable, result, resources, activities, currency, y) + drawResourcesSection(docAny, autoTable, result, resources, activities, currency, y, tab) } // ── Página 4 o 5: Nota metodológica ────────────────────────────────────────── diff --git a/src/lib/export/pdf-sections.ts b/src/lib/export/pdf-sections.ts index ae86aec..ba97fc3 100644 --- a/src/lib/export/pdf-sections.ts +++ b/src/lib/export/pdf-sections.ts @@ -485,7 +485,8 @@ export function drawResourcesSection( resources: Resource[], activities: Activity[], currency: string, - startY: number + startY: number, + scenario: 'actual' | 'automatizado' = 'actual' ): void { let y = startY const m = PDF.margin @@ -544,7 +545,9 @@ export function drawResourcesSection( for (const br of act.resourceCostBreakdown) { const resource = resourceMap.get(br.resourceId) - const assignment = activityRow?.assignedResources.find((a) => a.resourceId === br.resourceId) + const assignment = scenario === 'automatizado' + ? activityRow?.automatedAssignedResources?.find((a) => a.resourceId === br.resourceId) + : activityRow?.assignedResources.find((a) => a.resourceId === br.resourceId) const typeName = resource ? (TYPE_LABEL[resource.type] ?? resource.type) : '—' const typeColor = resource ? (TYPE_COLOR[resource.type] ?? PDF.slate50) : PDF.slate50 diff --git a/src/persistence/db.ts b/src/persistence/db.ts index 7455593..771a3df 100644 --- a/src/persistence/db.ts +++ b/src/persistence/db.ts @@ -1,5 +1,5 @@ import Dexie, { type Table } from 'dexie' -import type { Process, Activity, GatewayConfig, Resource, Simulation } from '@/domain/types' +import type { Process, Activity, GatewayConfig, Resource, Simulation, ProcessGroup, AppSettings } from '@/domain/types' export class ProcessCostDb extends Dexie { processes!: Table @@ -7,6 +7,8 @@ export class ProcessCostDb extends Dexie { gateways!: Table resources!: Table simulations!: Table + groups!: Table // 🆕 Sprint 3 + settings!: Table // 🆕 Sprint 3 constructor() { super('ProcessCostPlatform') @@ -79,6 +81,44 @@ export class ProcessCostDb extends Dexie { }) }) }) + + // Sprint 3: agrega tabla de grupos (Process Library), tabla de settings, + // campos groupId/tags en Process, y automatedAssignedResources en Activity. + this.version(4) + .stores({ + processes: 'id, name, updatedAt, groupId', // groupId indexado para queries por grupo + activities: 'id, processId, bpmnElementId', + gateways: 'id, processId, bpmnElementId', + resources: 'id, processId, name', + simulations: 'id, processId, executedAt', + groups: 'id, name, updatedAt', // nueva tabla + settings: 'id', // nueva tabla (singleton) + }) + .upgrade(async (tx) => { + // Process: agregar groupId (null) y tags ([]) + await tx.table('processes').toCollection().modify((proc) => { + if (proc.groupId === undefined) proc.groupId = null + if (!Array.isArray(proc.tags)) proc.tags = [] + }) + // Activity: agregar automatedAssignedResources ([]) + await tx.table('activities').toCollection().modify((act) => { + if (!Array.isArray(act.automatedAssignedResources)) { + act.automatedAssignedResources = [] + } + }) + // Settings: crear singleton con defaults (put es idempotente por clave primaria) + await tx.table('settings').put({ id: 'singleton', groupLabel: 'Cliente' }) + }) + + // Belt-and-suspenders: garantizar singleton en fresh installs y entornos de test. + // El upgrade también lo crea; este handler cubre el caso en que la tabla sea nueva + // pero el upgrade no haya persistido el put (conocido con fake-indexeddb). + this.on('ready', async () => { + const existing = await this.settings.get('singleton') + if (!existing) { + await this.settings.put({ id: 'singleton', groupLabel: 'Cliente' }) + } + }) } } diff --git a/src/persistence/repositories.ts b/src/persistence/repositories.ts index afc7ceb..e70ee4a 100644 --- a/src/persistence/repositories.ts +++ b/src/persistence/repositories.ts @@ -17,7 +17,7 @@ */ import { db } from './db' -import type { Process, Activity, GatewayConfig, Resource, Simulation } from '@/domain/types' +import type { Process, Activity, GatewayConfig, Resource, Simulation, ProcessGroup, AppSettings } from '@/domain/types' // ─── Process ───────────────────────────────────────────────────────────────── @@ -125,13 +125,56 @@ export const simulationRepo = { }, async getLatestByProcess(processId: string): Promise { - return db.simulations + // Ordena por executedAt descendente para retornar la simulación más reciente. + // No se usa .last() porque el primary key es UUID (no ordenado cronológicamente). + const all = await db.simulations .where('processId') .equals(processId) - .last() + .toArray() + if (!all.length) return undefined + return all.reduce((latest, sim) => sim.executedAt > latest.executedAt ? sim : latest) }, async getByProcess(processId: string): Promise { return db.simulations.where('processId').equals(processId).toArray() }, } + +// ─── ProcessGroup ───────────────────────────────────────────────────────────── + +/** @internal Solo llamar desde src/store/library-store.ts */ +export const groupRepo = { + async save(group: ProcessGroup): Promise { + await db.groups.put(group) + }, + + async getAll(): Promise { + return db.groups.orderBy('name').toArray() + }, + + async getById(id: string): Promise { + return db.groups.get(id) + }, + + async delete(id: string): Promise { + // Procesos del grupo pasan a "Sin clasificar" (groupId = null) + await db.transaction('rw', [db.groups, db.processes], async () => { + await db.processes.where('groupId').equals(id).modify({ groupId: null }) + await db.groups.delete(id) + }) + }, +} + +// ─── AppSettings ────────────────────────────────────────────────────────────── + +/** @internal Solo llamar desde src/store/library-store.ts */ +export const settingsRepo = { + async get(): Promise { + const s = await db.settings.get('singleton') + return s ?? { id: 'singleton', groupLabel: 'Cliente' } + }, + + async update(updates: Partial>): Promise { + await db.settings.update('singleton', updates) + }, +} diff --git a/src/router.tsx b/src/router.tsx index 8708438..7ca7038 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -2,6 +2,8 @@ import { lazy, Suspense } from 'react' import { createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/react-router' import { WorkspacePage } from '@/features/workspace/WorkspacePage' import { ImportPage } from '@/features/import/ImportPage' +import { LibraryPage } from '@/features/library/LibraryPage' +import { SettingsPage } from '@/features/settings/SettingsPage' import { Loader2 } from 'lucide-react' // ECharts es grande (373KB gzip). Lazy-load del ReportPage para que el @@ -28,6 +30,12 @@ const rootRoute = createRootRoute({ component: () => }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', + component: LibraryPage, +}) + +const importRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/import', component: ImportPage, }) @@ -43,7 +51,13 @@ const reportRoute = createRoute({ component: ReportPageWithSuspense, }) -const routeTree = rootRoute.addChildren([indexRoute, workspaceRoute, reportRoute]) +const settingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings', + component: SettingsPage, +}) + +const routeTree = rootRoute.addChildren([indexRoute, importRoute, workspaceRoute, reportRoute, settingsRoute]) export const router = createRouter({ routeTree }) declare module '@tanstack/react-router' { diff --git a/src/store/library-store.ts b/src/store/library-store.ts new file mode 100644 index 0000000..e0e3d77 --- /dev/null +++ b/src/store/library-store.ts @@ -0,0 +1,98 @@ +import { create } from 'zustand' +import { v4 as uuidv4 } from 'uuid' +import { processRepo, groupRepo, settingsRepo, simulationRepo } from '@/persistence/repositories' +import type { Process, ProcessGroup, AppSettings, Simulation } from '@/domain/types' + +interface LibraryState { + groups: ProcessGroup[] + processes: Process[] + settings: AppSettings + isLoading: boolean + + load: () => Promise + + createGroup: (name: string) => Promise + deleteGroup: (id: string) => Promise + + assignProcessToGroup: (processId: string, groupId: string | null) => Promise + + getProcessesByGroup: (groupId: string | null) => Process[] + getLatestSimulation: (processId: string) => Promise + + updateSettings: (updates: Partial>) => Promise + getAllTags: () => string[] +} + +const DEFAULT_SETTINGS: AppSettings = { id: 'singleton', groupLabel: 'Cliente' } + +export const useLibraryStore = create((set, get) => ({ + groups: [], + processes: [], + settings: DEFAULT_SETTINGS, + isLoading: false, + + load: async () => { + set({ isLoading: true }) + const [groups, rawProcesses, settings] = await Promise.all([ + groupRepo.getAll(), + processRepo.getAll(), + settingsRepo.get(), + ]) + // Normalizar datos legacy que puedan llegar sin tags/groupId del IndexedDB + const processes = rawProcesses.map((p) => ({ + ...p, + groupId: p.groupId ?? null, + tags: Array.isArray(p.tags) ? p.tags : [], + })) + set({ groups, processes, settings, isLoading: false }) + }, + + createGroup: async (name: string) => { + const now = Date.now() + const group: ProcessGroup = { id: uuidv4(), name, createdAt: now, updatedAt: now } + await groupRepo.save(group) + set((state) => ({ groups: [...state.groups, group].sort((a, b) => a.name.localeCompare(b.name)) })) + return group + }, + + deleteGroup: async (id: string) => { + await groupRepo.delete(id) + set((state) => ({ + groups: state.groups.filter((g) => g.id !== id), + // Los procesos del grupo pasan a groupId = null + processes: state.processes.map((p) => p.groupId === id ? { ...p, groupId: null } : p), + })) + }, + + assignProcessToGroup: async (processId: string, groupId: string | null) => { + const process = get().processes.find((p) => p.id === processId) + if (!process) return + const updated = { ...process, groupId, updatedAt: Date.now() } + await processRepo.save(updated) + set((state) => ({ + processes: state.processes.map((p) => p.id === processId ? updated : p), + })) + }, + + getProcessesByGroup: (groupId: string | null) => { + const processes = get().processes + return groupId === null + ? processes.filter((p) => p.groupId == null) + : processes.filter((p) => p.groupId === groupId) + }, + + getLatestSimulation: (processId: string) => { + return simulationRepo.getLatestByProcess(processId) + }, + + updateSettings: async (updates) => { + await settingsRepo.update(updates) + set((state) => ({ settings: { ...state.settings, ...updates } })) + }, + + getAllTags: () => { + const tagSet = new Set() + get().processes.forEach((p) => p.tags.forEach((t) => tagSet.add(t))) + return [...tagSet].sort() + }, +})) diff --git a/src/store/process-store.ts b/src/store/process-store.ts index b8ed538..f4de582 100644 --- a/src/store/process-store.ts +++ b/src/store/process-store.ts @@ -66,33 +66,80 @@ export const useProcessStore = create((set, get) => ({ updateActivity: async (activity) => { await activityRepo.save(activity) - set((state) => ({ - activities: state.activities.map((a) => (a.id === activity.id ? activity : a)), - })) + const current = get().currentProcess + if (current) { + const updatedProcess = { ...current, updatedAt: Date.now() } + await processRepo.save(updatedProcess) + set((state) => ({ + activities: state.activities.map((a) => (a.id === activity.id ? activity : a)), + currentProcess: updatedProcess, + })) + } else { + set((state) => ({ + activities: state.activities.map((a) => (a.id === activity.id ? activity : a)), + })) + } }, updateGateway: async (gateway) => { await gatewayRepo.save(gateway) - set((state) => ({ - gateways: state.gateways.map((g) => (g.id === gateway.id ? gateway : g)), - })) + const current = get().currentProcess + if (current) { + const updatedProcess = { ...current, updatedAt: Date.now() } + await processRepo.save(updatedProcess) + set((state) => ({ + gateways: state.gateways.map((g) => (g.id === gateway.id ? gateway : g)), + currentProcess: updatedProcess, + })) + } else { + set((state) => ({ + gateways: state.gateways.map((g) => (g.id === gateway.id ? gateway : g)), + })) + } }, addResource: async (resource) => { await resourceRepo.save(resource) - set((state) => ({ resources: [...state.resources, resource] })) + const current = get().currentProcess + if (current) { + const updatedProcess = { ...current, updatedAt: Date.now() } + await processRepo.save(updatedProcess) + set((state) => ({ resources: [...state.resources, resource], currentProcess: updatedProcess })) + } else { + set((state) => ({ resources: [...state.resources, resource] })) + } }, updateResource: async (resource) => { await resourceRepo.save(resource) - set((state) => ({ - resources: state.resources.map((r) => (r.id === resource.id ? resource : r)), - })) + const current = get().currentProcess + if (current) { + const updatedProcess = { ...current, updatedAt: Date.now() } + await processRepo.save(updatedProcess) + set((state) => ({ + resources: state.resources.map((r) => (r.id === resource.id ? resource : r)), + currentProcess: updatedProcess, + })) + } else { + set((state) => ({ + resources: state.resources.map((r) => (r.id === resource.id ? resource : r)), + })) + } }, deleteResource: async (resourceId) => { await resourceRepo.delete(resourceId) - set((state) => ({ resources: state.resources.filter((r) => r.id !== resourceId) })) + const current = get().currentProcess + if (current) { + const updatedProcess = { ...current, updatedAt: Date.now() } + await processRepo.save(updatedProcess) + set((state) => ({ + resources: state.resources.filter((r) => r.id !== resourceId), + currentProcess: updatedProcess, + })) + } else { + set((state) => ({ resources: state.resources.filter((r) => r.id !== resourceId) })) + } }, reset: () => set({ currentProcess: null, activities: [], gateways: [], resources: [], error: null }), diff --git a/src/store/simulation-store.ts b/src/store/simulation-store.ts index f42a821..c8b4345 100644 --- a/src/store/simulation-store.ts +++ b/src/store/simulation-store.ts @@ -11,6 +11,7 @@ interface SimulationState { resultActual: SimulationResult | null // resultado del escenario actual resultAutomated: SimulationResult | null // resultado del escenario automatizado lastSimulatedAt: Date | null // timestamp de la última simulación exitosa + lastPersistedExecutedAt: number | null // executedAt de la última sim guardada en IndexedDB (sobrevive recargas) isRunning: boolean error: string | null selectedActivityId: string | null @@ -24,25 +25,34 @@ interface SimulationState { // Invalida AMBOS resultados a null. Se llama cuando cambian datos del proceso. invalidateResults: () => void reset: () => void + loadLatestForProcess: (processId: string) => Promise } export const useSimulationStore = create((set) => ({ resultActual: null, resultAutomated: null, lastSimulatedAt: null, + lastPersistedExecutedAt: null, isRunning: false, error: null, selectedActivityId: null, persistResults: async (processId, actual, automated) => { + const executedAt = Date.now() await simulationRepo.save({ id: uuidv4(), processId, - executedAt: Date.now(), + executedAt, result: actual, resultAutomated: automated, }) - set({ resultActual: actual, resultAutomated: automated, lastSimulatedAt: new Date(), error: null }) + set({ + resultActual: actual, + resultAutomated: automated, + lastSimulatedAt: new Date(), + lastPersistedExecutedAt: executedAt, + error: null, + }) }, setRunning: (isRunning) => set({ isRunning }), @@ -57,10 +67,16 @@ export const useSimulationStore = create((set) => ({ resultActual: null, resultAutomated: null, lastSimulatedAt: null, + lastPersistedExecutedAt: null, isRunning: false, error: null, selectedActivityId: null, }), + + loadLatestForProcess: async (processId) => { + const sim = await simulationRepo.getLatestByProcess(processId) + set({ lastPersistedExecutedAt: sim?.executedAt ?? null }) + }, })) // Suscripción reactiva: cualquier cambio en activities o currentProcess del process-store diff --git a/tests/domain/simulation-sprint3.test.ts b/tests/domain/simulation-sprint3.test.ts new file mode 100644 index 0000000..34e14a8 --- /dev/null +++ b/tests/domain/simulation-sprint3.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { runSimulation } from '@/domain/simulation' +import type { Activity, Resource, SimulationInput } from '@/domain/types' + +// BPMN de un solo nodo — execProb = 1.0, sin gateways +const SINGLE_TASK_BPMN = ` + + + f1 + f1f2 + f2 + + + +` + +const makeResource = (id: string, costPerHour: number): Resource => ({ + id, processId: 'proc1', name: `Recurso ${id}`, type: 'role', costPerHour, +}) + +const baseActivity = (overrides: Partial = {}): Activity => ({ + id: 'act-taskA', processId: 'proc1', bpmnElementId: 'taskA', + name: 'Tarea A', type: 'task', + directCostFixed: 0, executionTimeMinutes: 0, + assignedResources: [], + automatable: true, + automatedCostFixed: 0, automatedTimeMinutes: 0, + automatedAssignedResources: [], + ...overrides, +}) + +const baseInput = (activity: Activity, resources: Resource[] = []): SimulationInput => ({ + processXml: SINGLE_TASK_BPMN, + activities: new Map([['taskA', activity]]), + gateways: new Map(), + resources: new Map(resources.map((r) => [r.id, r])), + globalSettings: { currency: 'USD', overheadPercentage: 0 }, + scenario: 'automated', +}) + +describe('Sprint 3 — automatedAssignedResources en motor de simulación', () => { + + it('Caso 1: solo costo fijo, sin recursos TO-BE — resourceCostBreakdown vacío', () => { + const act = baseActivity({ automatedCostFixed: 100, automatedTimeMinutes: 60, automatedAssignedResources: [] }) + const result = runSimulation(baseInput(act)) + + const actResult = result.perActivity[0] + expect(actResult.expectedDirectCost).toBeCloseTo(100) + expect(actResult.resourceCostBreakdown).toHaveLength(0) + }) + + it('Caso 2: solo recursos TO-BE — costo = costPerHour × (min/60) × units × execProb', () => { + const r1 = makeResource('r1', 30) + const act = baseActivity({ + automatedCostFixed: 0, automatedTimeMinutes: 30, + automatedAssignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 2 }], + }) + const result = runSimulation(baseInput(act, [r1])) + + // 30 $/h × (60min/60) × 2 units × 1.0 execProb = 60 + const actResult = result.perActivity[0] + expect(actResult.expectedDirectCost).toBeCloseTo(60) + expect(actResult.resourceCostBreakdown).toHaveLength(1) + expect(actResult.resourceCostBreakdown[0].resourceId).toBe('r1') + expect(actResult.resourceCostBreakdown[0].cost).toBeCloseTo(60) + }) + + it('Caso 3: mixto — costo fijo + recurso TO-BE se suman', () => { + const r1 = makeResource('r1', 60) + const act = baseActivity({ + automatedCostFixed: 50, automatedTimeMinutes: 30, + automatedAssignedResources: [{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }], + }) + const result = runSimulation(baseInput(act, [r1])) + + // fijo=50 + 60*(30/60)*1=30 → total=80 + const actResult = result.perActivity[0] + expect(actResult.expectedDirectCost).toBeCloseTo(80) + expect(actResult.resourceCostBreakdown[0].cost).toBeCloseTo(30) + }) + + it('Caso 4: actividad NO automatable — usa assignedResources, ignora automatedAssignedResources', () => { + const r1 = makeResource('r1', 60) + const r2 = makeResource('r2', 100) + const act = baseActivity({ + automatable: false, + directCostFixed: 0, + assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }], + automatedAssignedResources: [{ resourceId: 'r2', utilizationMinutes: 60, units: 1 }], + }) + const result = runSimulation(baseInput(act, [r1, r2])) + + // escenario automated pero automatable=false → usa r1 (assignedResources), no r2 + const actResult = result.perActivity[0] + expect(actResult.resourceCostBreakdown).toHaveLength(1) + expect(actResult.resourceCostBreakdown[0].resourceId).toBe('r1') + expect(actResult.resourceCostBreakdown[0].cost).toBeCloseTo(60) + }) +}) diff --git a/tests/domain/simulation.test.ts b/tests/domain/simulation.test.ts index e717c87..fa9de06 100644 --- a/tests/domain/simulation.test.ts +++ b/tests/domain/simulation.test.ts @@ -92,6 +92,7 @@ describe('runSimulation', () => { automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatedAssignedResources: [], }) it('calcula costo total sin recursos — proceso lineal', () => { @@ -220,6 +221,7 @@ describe('runSimulation — scenario automated', () => { opts: { directCost?: number; directTime?: number automatable?: boolean; automatedCost?: number; automatedTime?: number + automatedAssignedResources?: import('@/domain/types').ActivityResourceAssignment[] } = {} ): Activity => ({ id: `act-${bpmnElementId}`, @@ -233,6 +235,7 @@ describe('runSimulation — scenario automated', () => { automatable: opts.automatable ?? false, automatedCostFixed: opts.automatedCost ?? 0, automatedTimeMinutes: opts.automatedTime ?? 0, + automatedAssignedResources: opts.automatedAssignedResources ?? [], }) // ─── scenario=automated con costos en cero ──────────────────────────────── @@ -409,6 +412,7 @@ describe('runSimulation — recursos con modelo utilizationMinutes + units', () directCostFixed: directCost, executionTimeMinutes: minutes, assignedResources: resources, automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatedAssignedResources: [], }) it('actividad sin recursos: costo = directCostFixed', () => { diff --git a/tests/features/report/components.test.tsx b/tests/features/report/components.test.tsx index 0cb5aae..4702768 100644 --- a/tests/features/report/components.test.tsx +++ b/tests/features/report/components.test.tsx @@ -227,7 +227,7 @@ describe('MethodologyFooter', () => { id: 'p1', name: 'Proceso Test', clientName: '', bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, - createdAt: 0, updatedAt: 0, + createdAt: 0, updatedAt: 0, groupId: null, tags: [], } expect(() => render()).not.toThrow() }) @@ -237,7 +237,7 @@ describe('MethodologyFooter', () => { id: 'p1', name: 'Proc', clientName: '', bpmnXml: '', currency: 'USD', overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, - createdAt: 0, updatedAt: 0, + createdAt: 0, updatedAt: 0, groupId: null, tags: [], } render() const text = document.body.textContent ?? '' @@ -249,7 +249,7 @@ describe('MethodologyFooter', () => { id: 'p1', name: 'Proc', clientName: '', bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, - createdAt: 0, updatedAt: 0, + createdAt: 0, updatedAt: 0, groupId: null, tags: [], } render() const text = document.body.textContent ?? '' @@ -334,6 +334,8 @@ describe('ReportPage — con datos completos', () => { automationInvestment: 0, createdAt: Date.now() - 1000 * 60 * 30, updatedAt: Date.now() - 1000 * 60 * 30, + groupId: null, + tags: [], } const mockSimulation = { diff --git a/tests/features/report/derivations.test.ts b/tests/features/report/derivations.test.ts index e628a18..46ab4f3 100644 --- a/tests/features/report/derivations.test.ts +++ b/tests/features/report/derivations.test.ts @@ -31,7 +31,7 @@ function buildSimInput(xml: string, directCost: number, overhead: number, curren directCostFixed: directCost, executionTimeMinutes: 30, assignedResources: [], - automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [], }]) ) diff --git a/tests/features/report/roi-components.test.tsx b/tests/features/report/roi-components.test.tsx index ce8d982..663083e 100644 --- a/tests/features/report/roi-components.test.tsx +++ b/tests/features/report/roi-components.test.tsx @@ -157,9 +157,10 @@ describe('RoiKpiBar', () => { expect(screen.getByText(/Inmediato/i)).toBeInTheDocument() }) - it('ROI Infinity (inversión cero) → muestra "∞%"', () => { + it('ROI Infinity (inversión cero) → muestra "—" con texto accionable', () => { render() - expect(screen.getByText('∞%')).toBeInTheDocument() + expect(screen.getByText('—')).toBeInTheDocument() + expect(screen.getByText(/Configurá la inversión/i)).toBeInTheDocument() }) it('ahorro negativo: "breaksEvenInHorizon = false" → subtexto fuera del horizonte', () => { @@ -311,6 +312,7 @@ const makeActivityDomain = (id: string, name: string, automatable: boolean, zero automatable, automatedCostFixed: zeroCosts ? 0 : 50, automatedTimeMinutes: zeroCosts ? 0 : 10, + automatedAssignedResources: [], }) describe('TransparencySection', () => { @@ -586,6 +588,7 @@ const mockProcessBase = { currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000, + groupId: null, tags: [], } const mockSimulationBase = { @@ -598,6 +601,7 @@ const automtableActivity: Activity = { id: 'act-1', processId: 'test-proc-id', bpmnElementId: 'bpmn_a1', name: 'Recibir solicitud', type: 'task', directCostFixed: 500, executionTimeMinutes: 60, assignedResources: [], automatable: true, automatedCostFixed: 50, automatedTimeMinutes: 5, + automatedAssignedResources: [], } const nonAutomatableActivity: Activity = { diff --git a/tests/integration/db-migration-v3.test.ts b/tests/integration/db-migration-v3.test.ts index a89186e..79f878b 100644 --- a/tests/integration/db-migration-v3.test.ts +++ b/tests/integration/db-migration-v3.test.ts @@ -32,6 +32,7 @@ function makeActivity(overrides: Partial & { id: string; processId: st automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatedAssignedResources: [], } } diff --git a/tests/integration/dexie-migration.test.ts b/tests/integration/dexie-migration.test.ts index b345aa7..7fe4bab 100644 --- a/tests/integration/dexie-migration.test.ts +++ b/tests/integration/dexie-migration.test.ts @@ -16,7 +16,7 @@ import type { Activity, Process } from '@/domain/types' // ─── Fixtures de registros "viejos" (sin campos Sprint 1) ───────────────────── -function makeLegacyActivity(id: string, processId: string): Omit { +function makeLegacyActivity(id: string, processId: string): Omit { return { id, processId, bpmnElementId: `task_${id}`, @@ -28,7 +28,7 @@ function makeLegacyActivity(id: string, processId: string): Omit { +function makeLegacyProcess(id: string): Omit { return { id, name: 'Proceso Legacy', clientName: 'Cliente Viejo', bpmnXml: '', currency: 'USD', overheadPercentage: 0.15, @@ -59,6 +59,7 @@ describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () => automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatedAssignedResources: [], } await activityRepo.save(act) const retrieved = await activityRepo.getByProcess('p1') @@ -73,6 +74,7 @@ describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () => automatable: true, automatedCostFixed: 12.5, automatedTimeMinutes: 3, + automatedAssignedResources: [], } await activityRepo.save(act) const [retrieved] = await activityRepo.getByProcess('p1') @@ -87,6 +89,7 @@ describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () => automatable: true, automatedCostFixed: 5, automatedTimeMinutes: 10, + automatedAssignedResources: [], } await activityRepo.save(act) // Actualizar solo directCostFixed — los campos de automatización no deben cambiar @@ -105,6 +108,7 @@ describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => { annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, + groupId: null, tags: [], } await processRepo.save(proc) const retrieved = await processRepo.getById('proc-1') @@ -119,6 +123,7 @@ describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => { annualFrequency: 5000, analysisHorizonYears: 5, automationInvestment: 75_000, + groupId: null, tags: [], } await processRepo.save(proc) const retrieved = await processRepo.getById('proc-2') @@ -133,6 +138,7 @@ describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => { annualFrequency: 2000, analysisHorizonYears: 4, automationInvestment: 30_000, + groupId: null, tags: [], } await processRepo.save(proc) // Actualizar solo el nombre — los campos de volumetría no deben cambiar diff --git a/tests/integration/gateway-validation.test.ts b/tests/integration/gateway-validation.test.ts index e6d2772..f5279e6 100644 --- a/tests/integration/gateway-validation.test.ts +++ b/tests/integration/gateway-validation.test.ts @@ -85,7 +85,7 @@ describe('motor / propagación con GatewayConfig correcta', () => { actIds.map((id) => [id, { id: uuidv4(), processId, bpmnElementId: id, name: id, type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [], - automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [], }]) ) diff --git a/tests/integration/indexeddb-persistence.test.ts b/tests/integration/indexeddb-persistence.test.ts index e09b5af..10825eb 100644 --- a/tests/integration/indexeddb-persistence.test.ts +++ b/tests/integration/indexeddb-persistence.test.ts @@ -32,6 +32,8 @@ function makeProcess(id = 'proc-1'): Process { automationInvestment: 0, createdAt: 1000, updatedAt: 2000, + groupId: null, + tags: [], } } @@ -48,6 +50,7 @@ function makeActivity(id: string, processId: string, bpmnElementId: string): Act automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatedAssignedResources: [], } } diff --git a/tests/integration/simulation-engine.test.ts b/tests/integration/simulation-engine.test.ts index 35e0635..409ce34 100644 --- a/tests/integration/simulation-engine.test.ts +++ b/tests/integration/simulation-engine.test.ts @@ -56,6 +56,7 @@ function buildInput( automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatedAssignedResources: [], }, ]) ) diff --git a/tests/integration/sprint1-flow.test.ts b/tests/integration/sprint1-flow.test.ts index 3bed2a3..63005c7 100644 --- a/tests/integration/sprint1-flow.test.ts +++ b/tests/integration/sprint1-flow.test.ts @@ -51,6 +51,7 @@ function buildActivities(xml: string): Map { automatable: AUTOMATABLE_IDS.has(el.bpmnElementId), automatedCostFixed: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_COST : 0, automatedTimeMinutes: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_TIME : 0, + automatedAssignedResources: [], }]) ) } diff --git a/tests/integration/sprint1-report-roi.test.ts b/tests/integration/sprint1-report-roi.test.ts index e68eb4f..bed54a8 100644 --- a/tests/integration/sprint1-report-roi.test.ts +++ b/tests/integration/sprint1-report-roi.test.ts @@ -52,6 +52,7 @@ function buildSimInput( automatable: automatableIds.has(el.bpmnElementId), automatedCostFixed: automatableIds.has(el.bpmnElementId) ? automatedCost : 0, automatedTimeMinutes: automatableIds.has(el.bpmnElementId) ? automatedTime : 0, + automatedAssignedResources: [], }]) ) @@ -312,6 +313,7 @@ describe('Caso C — medium-with-gateways, 1 automatable en ceros + investment=0 automatable: el.bpmnElementId === 'task_valid', automatedCostFixed: 0, // cero — trigger de transparencia automatedTimeMinutes: 0, // cero — trigger de transparencia + automatedAssignedResources: [], }]) ) diff --git a/tests/lib/export/csv-export-roi.test.ts b/tests/lib/export/csv-export-roi.test.ts index 86cd099..9998024 100644 --- a/tests/lib/export/csv-export-roi.test.ts +++ b/tests/lib/export/csv-export-roi.test.ts @@ -14,7 +14,7 @@ function makeProcess(extras: Partial = {}): Process { id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ', bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, - createdAt: 0, updatedAt: 0, ...extras, + createdAt: 0, updatedAt: 0, groupId: null, tags: [], ...extras, } } @@ -66,11 +66,13 @@ const mockActivities: Activity[] = [ id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task', directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [], automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5, + automatedAssignedResources: [], }, { id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatedAssignedResources: [], }, ] diff --git a/tests/lib/export/csv-export.test.ts b/tests/lib/export/csv-export.test.ts index fea051e..ddfbd06 100644 --- a/tests/lib/export/csv-export.test.ts +++ b/tests/lib/export/csv-export.test.ts @@ -9,7 +9,7 @@ function makeProcess(currency: string, extras: Partial = {}): Process { id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC', bpmnXml: '', currency, overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, - createdAt: 0, updatedAt: 0, ...extras, + createdAt: 0, updatedAt: 0, groupId: null, tags: [], ...extras, } } diff --git a/tests/lib/export/measure-sizes.test.ts b/tests/lib/export/measure-sizes.test.ts index f0afdde..89a1855 100644 --- a/tests/lib/export/measure-sizes.test.ts +++ b/tests/lib/export/measure-sizes.test.ts @@ -26,7 +26,7 @@ function buildInput(xml: string, directCost: number): SimulationInput { actEls.map((el) => [el.bpmnElementId, { id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, name: el.name, type: el.type, directCostFixed: directCost, executionTimeMinutes: 30, assignedResources: [], - automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [], }]) ) @@ -66,6 +66,7 @@ describe('Medición de tamaños de CSV — 3 sample BPMNs', () => { bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, createdAt: 0, updatedAt: 0, + groupId: null, tags: [], } const simulation: Simulation = { diff --git a/tests/lib/export/pdf-export-roi.test.ts b/tests/lib/export/pdf-export-roi.test.ts index 167d5c7..e262ada 100644 --- a/tests/lib/export/pdf-export-roi.test.ts +++ b/tests/lib/export/pdf-export-roi.test.ts @@ -51,6 +51,7 @@ const mockProcess: Process = { bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, createdAt: 0, updatedAt: 0, + groupId: null, tags: [], } const perActivityActual = [ @@ -101,11 +102,13 @@ const mockActivities: Activity[] = [ id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task', directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [], automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5, + automatedAssignedResources: [], }, { id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatedAssignedResources: [], }, ] diff --git a/tests/lib/export/pdf-export-sprint2.test.ts b/tests/lib/export/pdf-export-sprint2.test.ts index 4699087..7055155 100644 --- a/tests/lib/export/pdf-export-sprint2.test.ts +++ b/tests/lib/export/pdf-export-sprint2.test.ts @@ -43,6 +43,7 @@ const mockProcess: Process = { bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, createdAt: 0, updatedAt: 0, + groupId: null, tags: [], } // Simulación SIN recursos en ninguna actividad @@ -72,6 +73,7 @@ const mockActivity: Activity = { directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatedAssignedResources: [], } const mockSimWithResources: Simulation = { diff --git a/tests/lib/export/pdf-export.test.ts b/tests/lib/export/pdf-export.test.ts index 7693980..a42523f 100644 --- a/tests/lib/export/pdf-export.test.ts +++ b/tests/lib/export/pdf-export.test.ts @@ -54,6 +54,7 @@ const mockProcess: Process = { overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, createdAt: 0, updatedAt: 0, + groupId: null, tags: [], } const mockSimulation: Simulation = { diff --git a/tests/lib/export/pdf-orientation.test.ts b/tests/lib/export/pdf-orientation.test.ts index f81ad5b..59cc7bb 100644 --- a/tests/lib/export/pdf-orientation.test.ts +++ b/tests/lib/export/pdf-orientation.test.ts @@ -59,6 +59,7 @@ const mockProcess: Process = { overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000, createdAt: 0, updatedAt: 0, + groupId: null, tags: [], } const mockSimulation: Simulation = { diff --git a/tests/lib/export/pdf-resources-section.test.ts b/tests/lib/export/pdf-resources-section.test.ts index f916877..f5d7ecc 100644 --- a/tests/lib/export/pdf-resources-section.test.ts +++ b/tests/lib/export/pdf-resources-section.test.ts @@ -30,6 +30,7 @@ const mockActivity: Activity = { directCostFixed: 600, executionTimeMinutes: 60, assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatedAssignedResources: [{ resourceId: 'r1', utilizationMinutes: 5, units: 3 }], } const activityWithResources: ActivitySimResult = { @@ -122,3 +123,43 @@ describe('drawResourcesSection', () => { expect(autoTableMock).toHaveBeenCalledTimes(1) }) }) + +// ─── Escenario automatizado usa automatedAssignedResources ──────────────────── + +describe('drawResourcesSection — escenario automatizado', () => { + let autoTableMock: ReturnType + + beforeEach(() => { + autoTableMock = vi.fn() + }) + + it('escenario automatizado: pasa utilizationMinutes y units de automatedAssignedResources', async () => { + const { drawResourcesSection } = await import('@/lib/export/pdf-sections') + drawResourcesSection( + mockDoc, autoTableMock, + { perActivity: [activityWithResources] }, + [mockResource], [mockActivity], 'USD', 40, + 'automatizado' + ) + const body = (autoTableMock.mock.calls[0][1] as any).body as any[][] + const row = body[0] + // Min. (índice 4) y Uds. (índice 5) deben venir de automatedAssignedResources: { utilMin: 5, units: 3 } + expect(row[4].content).toBe('5') + expect(row[5].content).toBe('3') + }) + + it('escenario actual: pasa utilizationMinutes y units de assignedResources (sin regresión)', async () => { + const { drawResourcesSection } = await import('@/lib/export/pdf-sections') + drawResourcesSection( + mockDoc, autoTableMock, + { perActivity: [activityWithResources] }, + [mockResource], [mockActivity], 'USD', 40, + 'actual' + ) + const body = (autoTableMock.mock.calls[0][1] as any).body as any[][] + const row = body[0] + // assignedResources: { utilMin: 60, units: 1 } + expect(row[4].content).toBe('60') + expect(row[5].content).toBe('1') + }) +}) diff --git a/tests/lib/export/pdf-roi-page1.test.ts b/tests/lib/export/pdf-roi-page1.test.ts index cb04b66..c2a838e 100644 --- a/tests/lib/export/pdf-roi-page1.test.ts +++ b/tests/lib/export/pdf-roi-page1.test.ts @@ -56,6 +56,7 @@ const mockProcess: Process = { overheadPercentage: 0.2, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, createdAt: 0, updatedAt: 0, + groupId: null, tags: [], } const mockSimulation: Simulation = { diff --git a/tests/lib/export/pdf-roi-pages2-5.test.ts b/tests/lib/export/pdf-roi-pages2-5.test.ts index e59934b..448dcb4 100644 --- a/tests/lib/export/pdf-roi-pages2-5.test.ts +++ b/tests/lib/export/pdf-roi-pages2-5.test.ts @@ -52,6 +52,7 @@ const mockProcess: Process = { bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, createdAt: 0, updatedAt: 0, + groupId: null, tags: [], } const mockRoi: RoiResult = { @@ -103,10 +104,10 @@ const mockSimulation: Simulation = { const mockActivities: Activity[] = [ { id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'Tarea A', type: 'task', automatable: true, directCostFixed: 400, executionTimeMinutes: 30, - automatedCostFixed: 40, automatedTimeMinutes: 5, assignedResources: [] }, + automatedCostFixed: 40, automatedTimeMinutes: 5, assignedResources: [], automatedAssignedResources: [] }, { id: 'a2', processId: 'p1', bpmnElementId: 't2', name: 'Tarea B', type: 'task', automatable: false, directCostFixed: 200, executionTimeMinutes: 20, - automatedCostFixed: 0, automatedTimeMinutes: 0, assignedResources: [] }, + automatedCostFixed: 0, automatedTimeMinutes: 0, assignedResources: [], automatedAssignedResources: [] }, ] describe('PDF ROI — Etapa 7 páginas 2-5', () => { diff --git a/tests/lib/export/pdf-scenario-page1.test.ts b/tests/lib/export/pdf-scenario-page1.test.ts index 9a1f6ee..20cba98 100644 --- a/tests/lib/export/pdf-scenario-page1.test.ts +++ b/tests/lib/export/pdf-scenario-page1.test.ts @@ -52,6 +52,7 @@ const mockProcess: Process = { bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, createdAt: 0, updatedAt: 0, + groupId: null, tags: [], } const mockSimulation: Simulation = { diff --git a/tests/persistence/migration-v4.test.ts b/tests/persistence/migration-v4.test.ts new file mode 100644 index 0000000..219619a --- /dev/null +++ b/tests/persistence/migration-v4.test.ts @@ -0,0 +1,220 @@ +/** + * Tests de migración Dexie v3 → v4 (Sprint 3). + * + * Verifican que: + * 1. Proceso existente conserva todos sus campos y recibe groupId=null, tags=[]. + * 2. Activity existente conserva assignedResources y recibe automatedAssignedResources=[]. + * 3. Settings singleton creado con groupLabel='Cliente' durante el upgrade. + * 4. El upgrade es idempotente: datos ya migrados no se corrompen. + * 5. groupId puede ser un UUID válido y es queryable como índice. + * + * Usan fake-indexeddb (parcheado en tests/setup.ts). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { db } from '@/persistence/db' +import type { Process, ProcessGroup } from '@/domain/types' + +// ─── Helper ─────────────────────────────────────────────────────────────────── + +function makeProcess(id: string, overrides: Partial = {}): Process { + return { + id, + name: `Proceso ${id}`, + clientName: 'Cliente Test', + bpmnXml: '', + currency: 'USD', + overheadPercentage: 0.2, + annualFrequency: 1000, + analysisHorizonYears: 3, + automationInvestment: 0, + createdAt: 1000, + updatedAt: 2000, + groupId: null, + tags: [], + ...overrides, + } +} + +// ─── Test 1 — Process: lógica del upgrade (puro, sin base de datos) ─────────── + +describe('Migración Dexie v4 — Process: lógica de upgrade', () => { + it('proceso v3 (sin groupId ni tags) recibe defaults correctos preservando campos originales', () => { + const proc: Record = { + id: 'p1', + name: 'Proceso Legado', + clientName: 'Cliente A', + bpmnXml: '', + currency: 'PYG', + overheadPercentage: 0.15, + annualFrequency: 500, + analysisHorizonYears: 5, + automationInvestment: 50_000, + createdAt: 1000, + updatedAt: 2000, + // Sin groupId ni tags — formato v3 + } + + // Simular callback del upgrade + if (proc.groupId === undefined) proc.groupId = null + if (!Array.isArray(proc.tags)) proc.tags = [] + + // Campos originales intactos + expect(proc.id).toBe('p1') + expect(proc.name).toBe('Proceso Legado') + expect(proc.clientName).toBe('Cliente A') + expect(proc.currency).toBe('PYG') + expect(proc.overheadPercentage).toBe(0.15) + expect(proc.annualFrequency).toBe(500) + expect(proc.analysisHorizonYears).toBe(5) + expect(proc.automationInvestment).toBe(50_000) + expect(proc.createdAt).toBe(1000) + expect(proc.updatedAt).toBe(2000) + // Nuevos campos con defaults correctos + expect(proc.groupId).toBeNull() + expect(proc.tags).toEqual([]) + }) +}) + +// ─── Test 2 — Activity: lógica del upgrade (puro, sin base de datos) ───────── + +describe('Migración Dexie v4 — Activity: lógica de upgrade', () => { + it('activity v3 con assignedResources poblados recibe automatedAssignedResources=[]', () => { + const act: Record = { + id: 'a1', + processId: 'p1', + bpmnElementId: 'task_a1', + name: 'Tarea A', + type: 'task', + directCostFixed: 500, + executionTimeMinutes: 60, + assignedResources: [{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }], + automatable: true, + automatedCostFixed: 200, + automatedTimeMinutes: 15, + // Sin automatedAssignedResources — formato v3 + } + + // Simular callback del upgrade + if (!Array.isArray(act.automatedAssignedResources)) { + act.automatedAssignedResources = [] + } + + // assignedResources no fue alterado + expect(act.assignedResources).toEqual([{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }]) + // campos de automatización intactos + expect(act.automatable).toBe(true) + expect(act.automatedCostFixed).toBe(200) + expect(act.automatedTimeMinutes).toBe(15) + // nuevo campo vacío + expect(act.automatedAssignedResources).toEqual([]) + }) +}) + +// ─── Test 3 — Settings singleton (integración) ─────────────────────────────── + +describe('Migración Dexie v4 — Settings singleton', () => { + beforeEach(async () => { + await db.open() + }) + + afterEach(async () => { + await db.close() + }) + + it('singleton creado con groupLabel=Cliente durante el upgrade', async () => { + const settings = await db.settings.get('singleton') + expect(settings).toBeDefined() + expect(settings!.id).toBe('singleton') + expect(settings!.groupLabel).toBe('Cliente') + }) +}) + +// ─── Test 4 — Idempotencia (puro, sin base de datos) ───────────────────────── + +describe('Migración Dexie v4 — Idempotencia', () => { + it('upgrade no corrompe proceso ya migrado (groupId y tags con valores)', () => { + const proc: Record = { + id: 'p2', + groupId: null, + tags: ['facturación', 'BPMN'], + } + + // Simular upgrade (idempotente) + if (proc.groupId === undefined) proc.groupId = null + if (!Array.isArray(proc.tags)) proc.tags = [] + + expect(proc.groupId).toBeNull() + expect(proc.tags).toEqual(['facturación', 'BPMN']) + }) + + it('upgrade no corrompe activity ya migrada (automatedAssignedResources poblado)', () => { + const act: Record = { + id: 'a2', + automatedAssignedResources: [{ resourceId: 'r2', utilizationMinutes: 10, units: 2 }], + } + + if (!Array.isArray(act.automatedAssignedResources)) { + act.automatedAssignedResources = [] + } + + expect(act.automatedAssignedResources).toEqual([ + { resourceId: 'r2', utilizationMinutes: 10, units: 2 }, + ]) + }) +}) + +// ─── Test 5 — groupId como UUID válido (integración) ───────────────────────── + +describe('Migración Dexie v4 — groupId como UUID válido', () => { + beforeEach(async () => { + await db.open() + await db.transaction('rw', [db.processes, db.groups], async () => { + await db.processes.clear() + await db.groups.clear() + }) + }) + + afterEach(async () => { + await db.close() + }) + + it('proceso con groupId persiste y es queryable por el índice groupId', async () => { + const group: ProcessGroup = { + id: 'group-abc-123', + name: 'Grupo Test', + createdAt: 1000, + updatedAt: 2000, + } + await db.groups.put(group) + + const proc: Process = makeProcess('p3', { + groupId: 'group-abc-123', + tags: ['test'], + }) + await db.processes.put(proc) + + // El campo persiste + const retrieved = await db.processes.get('p3') + expect(retrieved!.groupId).toBe('group-abc-123') + expect(retrieved!.tags).toEqual(['test']) + + // El índice funciona para query por grupo + const byGroup = await db.processes.where('groupId').equals('group-abc-123').toArray() + expect(byGroup).toHaveLength(1) + expect(byGroup[0].id).toBe('p3') + }) + + it('proceso con groupId=null persiste y es recuperable', async () => { + const proc: Process = makeProcess('p4', { groupId: null, tags: [] }) + await db.processes.put(proc) + + const retrieved = await db.processes.get('p4') + expect(retrieved).toBeDefined() + expect(retrieved!.groupId).toBeNull() + + // Verificar con filter (Dexie no acepta null en .equals()) + const sinGrupo = await db.processes.filter((p) => p.groupId == null).toArray() + expect(sinGrupo.some((p) => p.id === 'p4')).toBe(true) + }) +}) diff --git a/tests/store/simulation-store-invalidation.test.ts b/tests/store/simulation-store-invalidation.test.ts index 7c75b16..f7b3553 100644 --- a/tests/store/simulation-store-invalidation.test.ts +++ b/tests/store/simulation-store-invalidation.test.ts @@ -94,6 +94,7 @@ describe('simulation store — invalidación por cambio en process store', () => currency: 'USD', overheadPercentage: 0.2, annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000, createdAt: Date.now(), updatedAt: Date.now(), + groupId: null, tags: [], } useProcessStore.setState({ currentProcess: proc }) @@ -110,7 +111,7 @@ describe('simulation store — invalidación por cambio en process store', () => type: 'task', directCostFixed: 999, // campo modificado executionTimeMinutes: 30, assignedResources: [], - automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [], }] useProcessStore.setState({ activities: newActivities }) @@ -122,7 +123,7 @@ describe('simulation store — invalidación por cambio en process store', () => const activities: Activity[] = [{ id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'T', type: 'task', directCostFixed: 100, executionTimeMinutes: 30, - assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, + assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [], }] useProcessStore.setState({ activities })