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 0000000..a5ac695 Binary files /dev/null and b/public/inquality-logo-color.png differ diff --git a/public/inquality-logo-white.png b/public/inquality-logo-white.png new file mode 100644 index 0000000..a79c00d Binary files /dev/null and b/public/inquality-logo-white.png differ 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 })