feat(sprint-3): biblioteca, recursos TO-BE, ROI mejorado e identidad de marca

Sprint 3 completo — 7 etapas:

- Etapa 1-2: biblioteca de procesos con agrupación por cliente (Dexie v4,
  LibraryPage, library-store, groupId/tags en Process)
- Etapa 3: modal de configuración global por proceso (horizonte, frecuencia,
  inversión, moneda) con validación y persistencia
- Etapa 4: panel de recursos rediseñado con soporte de unidades y minutos
  de utilización (utilizationMinutes + units)
- Etapa 5: recursos en escenario automatizado — motor de simulación,
  UI de edición en ActivityPanel, comparación AS-IS/TO-BE en reporte
- Etapa 6: PDF recursos automatizados, automatedAssignedResources requerido,
  561 tests pasando
- Etapa 7: identidad de marca — AppHeader gradiente naranja/magenta con logo
  InQuality en 5 páginas, favicon con logo real, logo/marca navegan a home

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 11:49:43 -03:00
parent d2746f07d1
commit 67e259e6ed
57 changed files with 4372 additions and 82 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 622 B

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

440
sprints/sprint-3/BRIEF.md Normal file
View File

@@ -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) | 6070 % |
| PDF (refinamientos, nuevas secciones) | 1525 % |
| Infraestructura / deuda técnica | 1015 % |
| Documentación / metodología | 510 % |
### 1.2 Criterio de wow (obligatorio)
Cada etapa con feature nueva incluye:
- **Versión mínima:** cumple la funcionalidad.
- **Versión con wow:** mismo objetivo + 12 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 AG: 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

View File

@@ -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<Process>
activities!: Table<Activity>
gateways!: Table<GatewayConfig>
resources!: Table<Resource>
simulations!: Table<Simulation>
groups!: Table<ProcessGroup> // 🆕 Sprint 3
settings!: Table<AppSettings> // 🆕 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)

View File

@@ -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<void> {
await db.groups.put(group)
},
async getAll(): Promise<ProcessGroup[]> {
return db.groups.orderBy('name').toArray()
},
async getById(id: string): Promise<ProcessGroup | undefined> {
return db.groups.get(id)
},
async delete(id: string): Promise<void> {
// 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<AppSettings> {
const s = await db.settings.get('singleton')
return s ?? { id: 'singleton', groupLabel: 'Cliente' }
},
async update(updates: Partial<Omit<AppSettings, 'id'>>): Promise<void> {
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<void>
// Grupos
createGroup: (name: string) => Promise<ProcessGroup>
deleteGroup: (id: string) => Promise<void>
// Procesos
assignProcessToGroup: (processId: string, groupId: string | null) => Promise<void>
// Computed helpers (derivados del estado, no acciones async)
getProcessesByGroup: (groupId: string | null) => Process[]
getLatestSimulation: (processId: string) => Promise<Simulation | undefined>
}
```
`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)

View File

@@ -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<Omit<AppSettings, 'id'>>) => Promise<void>
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<string>()
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)

View File

@@ -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 `<div>` 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<void> // 🆕
}
```
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 <div ref={containerRef} className={className} style={{ width: '100%', height: '100%' }} />
```
Cambiar a wrapper con botón superpuesto:
```tsx
return (
<div className={className} style={{ position: 'relative', width: '100%', height: '100%' }}>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
<button
onClick={() => {
const canvas = viewerRef.current?.get('canvas') as any
canvas?.zoom('fit-viewport')
}}
title="Encuadrar diagrama"
style={{
position: 'absolute',
bottom: 12,
right: 12,
width: 32,
height: 32,
borderRadius: 6,
background: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
zIndex: 10,
}}
>
<Maximize2 className="h-4 w-4 text-muted-foreground" />
</button>
</div>
)
```
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)

View File

@@ -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 14 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 */}
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
Recursos TO-BE
</p>
{/* Dropdown para agregar recurso */}
{availableAutomatedResources.length > 0 && (
<Select onValueChange={addAutomatedResource}>
<SelectTrigger className="h-7 text-xs w-[120px]">
<SelectValue placeholder="+ Agregar" />
</SelectTrigger>
<SelectContent>
{availableAutomatedResources.map((r) => (
<SelectItem key={r.id} value={r.id} className="text-xs">
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Lista de recursos asignados al escenario automatizado */}
{(localActivity.automatedAssignedResources ?? []).length === 0 ? (
<p className="text-xs text-slate-400 italic">
Sin recursos TO-BE solo costo fijo automatizado.
</p>
) : (
<div className="space-y-2">
{(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 (
<div key={assignment.resourceId} className="rounded-lg border border-border bg-card p-2 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium truncate max-w-[120px]">{resource.name}</span>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-slate-500">
{formatCurrency(cost, currency)}
</span>
<button
type="button"
onClick={() => removeAutomatedResource(assignment.resourceId)}
className="text-slate-400 hover:text-red-500 transition-colors"
aria-label={`Quitar ${resource.name}`}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Utilización y unidades */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-0.5">
<p className="text-[10px] text-slate-400">Min. por ejecución</p>
<Input
type="number"
min={0}
step={1}
value={assignment.utilizationMinutes || ''}
onChange={(e) =>
updateAutomatedAssignment(
assignment.resourceId,
'utilizationMinutes',
Math.max(0, parseFloat(e.target.value) || 0)
)
}
className="h-6 text-xs font-mono"
/>
</div>
<div className="w-16 space-y-0.5">
<p className="text-[10px] text-slate-400">Unidades</p>
<Input
type="number"
min={1}
step={1}
value={assignment.units || ''}
onChange={(e) =>
updateAutomatedAssignment(
assignment.resourceId,
'units',
Math.max(1, parseInt(e.target.value) || 1)
)
}
className="h-6 text-xs font-mono"
/>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
```
**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<string, Array<{ resourceId: string; resourceName: string; cost: number }>>
// ...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] && (
<>
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mt-1">AS-IS</p>
{/* Lista del pairedBreakdown (recursos AS-IS) */}
{pairedBreakdown[act.activityId].map((r) => (
<div key={r.resourceId} className="flex justify-between text-xs">
<span className="text-slate-600">{r.resourceName}</span>
<span className="font-mono">{formatCurrency(r.cost, currency)}</span>
</div>
))}
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mt-2">TO-BE</p>
{act.resourceCostBreakdown.length === 0
? <p className="text-xs text-slate-400 italic">Sin recursos TO-BE</p>
: act.resourceCostBreakdown.map((r) => (
<div key={r.resourceId} className="flex justify-between text-xs">
<span className="text-slate-600">{r.resourceName}</span>
<span className="font-mono">{formatCurrency(r.cost, currency)}</span>
</div>
))
}
</>
)}
```
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
<ActivitiesTable
perActivity={resultAutomated.perActivity}
pairedBreakdown={actualBreakdownById}
{/* ...props existentes... */}
/>
```
---
## ❌ 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)

View File

@@ -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 15 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<Activity, 'automatable' | 'automatedCostFixed' | 'automatedTimeMinutes'>`. 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 14) 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)

View File

@@ -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 16 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
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<!-- Fondo redondeado naranja InQ -->
<rect width="32" height="32" rx="7" fill="#F59845"/>
<!-- Letra "Q" en blanco — representa el producto InQ ROI -->
<text
x="16" y="23"
font-family="Georgia, 'Times New Roman', serif"
font-size="22"
font-weight="700"
fill="white"
text-anchor="middle"
>Q</text>
</svg>
```
### `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 (
<header
style={{
background: 'linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)',
height: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingLeft: 20,
paddingRight: 20,
flexShrink: 0,
}}
>
{/* Logo InQuality blanco */}
<img
src="/inquality-logo-white.png"
alt="InQuality"
style={{ height: 24, objectFit: 'contain' }}
/>
{/* Nombre del producto */}
<span
style={{
color: 'white',
fontSize: 13,
fontWeight: 600,
letterSpacing: '0.04em',
opacity: 0.92,
}}
>
InQ ROI
</span>
</header>
)
}
```
**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 `<AppHeader />` como primer elemento del render raíz:
```tsx
import { AppHeader } from '@/components/AppHeader'
// En el return del componente:
return (
<div className="min-h-screen bg-background">
<AppHeader /> {/* 🆕 */}
<div className="max-w-4xl mx-auto px-6 py-8">
{/* contenido existente sin cambio */}
</div>
</div>
)
```
### `src/features/workspace/WorkspacePage.tsx`
Agregar `<AppHeader />` antes del `<header>` interno de la workspace:
```tsx
import { AppHeader } from '@/components/AppHeader'
// En el return del componente (el div raíz flex-col):
return (
<div className="h-screen flex flex-col overflow-hidden">
<AppHeader /> {/* 🆕 — encima del header del workspace */}
<header className="min-h-12 bg-white border-b border-slate-200 ...">
{/* header existente sin cambio */}
</header>
{/* resto sin cambio */}
</div>
)
```
### `src/features/report/ReportPage.tsx`
Agregar `<AppHeader />` al inicio del div raíz:
```tsx
import { AppHeader } from '@/components/AppHeader'
// En el return del componente:
return (
<TooltipProvider>
<div className="min-h-screen bg-slate-50">
<AppHeader /> {/* 🆕 */}
{/* banner stale y resto sin cambio */}
</div>
</TooltipProvider>
)
```
### `src/features/import/ImportPage.tsx`
Agregar `<AppHeader />` al inicio del div raíz de la página.
### `src/features/settings/SettingsPage.tsx`
Agregar `<AppHeader />` 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
<p className="text-xs text-slate-400">
InQ ROI · Powered by InQuality · v0.1.0 · Hecho desde Paraguay 🇵🇾
</p>
```
---
## ❌ 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)

View File

@@ -1,7 +1,7 @@
export function AppFooter() { export function AppFooter() {
return ( return (
<footer className="mt-12 py-4 border-t border-slate-100 text-center"> <footer className="mt-12 py-4 border-t border-slate-100 text-center">
<p className="text-xs text-slate-300"> <p className="text-xs text-slate-400">
InQ ROI · Powered by InQuality · v0.1.0 · Hecho desde Paraguay 🇵🇾 InQ ROI · Powered by InQuality · v0.1.0 · Hecho desde Paraguay 🇵🇾
</p> </p>
</footer> </footer>

View File

@@ -0,0 +1,39 @@
import { Link } from '@tanstack/react-router'
export function AppHeader() {
return (
<header
style={{
background: 'linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)',
height: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingLeft: 20,
paddingRight: 20,
flexShrink: 0,
}}
>
<Link to="/" style={{ display: 'flex', alignItems: 'center' }}>
<img
src="/inquality-logo-white.png"
alt="InQuality"
style={{ height: 24, objectFit: 'contain', cursor: 'pointer' }}
/>
</Link>
<Link
to="/"
style={{
color: 'white',
fontSize: 13,
fontWeight: 600,
letterSpacing: '0.04em',
opacity: 0.92,
textDecoration: 'none',
}}
>
InQ ROI
</Link>
</header>
)
}

View File

@@ -204,7 +204,11 @@ export function runSimulation(input: SimulationInput): SimulationResult {
const resourceBreakdown: ActivitySimResult['resourceCostBreakdown'] = [] const resourceBreakdown: ActivitySimResult['resourceCostBreakdown'] = []
let resourceCostDirect = 0 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) const resource = resources.get(assignment.resourceId)
if (!resource) continue if (!resource) continue
const hoursUsed = (assignment.utilizationMinutes / 60) * assignment.units const hoursUsed = (assignment.utilizationMinutes / 60) * assignment.units

View File

@@ -16,6 +16,8 @@ export interface Process {
automationInvestment: number // 🆕 Sprint 1 — inversión total del proyecto, default 0 automationInvestment: number // 🆕 Sprint 1 — inversión total del proyecto, default 0
createdAt: number createdAt: number
updatedAt: 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' export type ActivityType = 'task' | 'subprocess'
@@ -32,6 +34,7 @@ export interface Activity {
automatable: boolean // 🆕 Sprint 1 — si la actividad es candidata a automatización automatable: boolean // 🆕 Sprint 1 — si la actividad es candidata a automatización
automatedCostFixed: number // 🆕 Sprint 1 — costo recurrente por ejecución automatizada automatedCostFixed: number // 🆕 Sprint 1 — costo recurrente por ejecución automatizada
automatedTimeMinutes: number // 🆕 Sprint 1 — tiempo de la actividad en escenario automatizado 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 { export interface ActivityResourceAssignment {
@@ -74,6 +77,20 @@ export interface Simulation {
resultAutomated?: SimulationResult // 🆕 Sprint 1 — escenario automatizado (opcional por retrocompatibilidad) 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 ─────────────────────────────────────── // ─── Motor de simulación: Input/Output ───────────────────────────────────────
// Escenario de simulación: actual (campos directCostFixed/executionTimeMinutes) // Escenario de simulación: actual (campos directCostFixed/executionTimeMinutes)

View File

@@ -1,4 +1,4 @@
import { useNavigate } from '@tanstack/react-router' import { useNavigate, useSearch } from '@tanstack/react-router'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react' import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
@@ -9,6 +9,7 @@ import type { Process, Activity, GatewayConfig } from '@/domain/types'
import { bpmnTypeToGatewayType } from '@/domain/types' import { bpmnTypeToGatewayType } from '@/domain/types'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { AppFooter } from '@/components/AppFooter' import { AppFooter } from '@/components/AppFooter'
import { AppHeader } from '@/components/AppHeader'
const SAMPLE_PROCESSES = [ const SAMPLE_PROCESSES = [
{ {
@@ -38,6 +39,10 @@ export function ImportPage() {
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
const [loadingSampleId, setLoadingSampleId] = useState<string | null>(null) const [loadingSampleId, setLoadingSampleId] = useState<string | null>(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) => { const processFile = useCallback(async (file: File) => {
if (!file.name.endsWith('.bpmn') && !file.name.endsWith('.xml')) { if (!file.name.endsWith('.bpmn') && !file.name.endsWith('.xml')) {
toast({ toast({
@@ -69,6 +74,8 @@ export function ImportPage() {
automationInvestment: 0, automationInvestment: 0,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
groupId: targetGroupId,
tags: [],
} }
const activityElements = extractActivityElements(graph) const activityElements = extractActivityElements(graph)
@@ -84,6 +91,7 @@ export function ImportPage() {
automatable: false, automatable: false,
automatedCostFixed: 0, automatedCostFixed: 0,
automatedTimeMinutes: 0, automatedTimeMinutes: 0,
automatedAssignedResources: [],
})) }))
const gatewayElements = extractGatewayElements(graph) const gatewayElements = extractGatewayElements(graph)
@@ -167,7 +175,9 @@ export function ImportPage() {
} }
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col items-center justify-center p-6"> <div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
<AppHeader />
<div className="flex-1 flex flex-col items-center justify-center p-6">
{/* Header */} {/* Header */}
<div className="text-center mb-10"> <div className="text-center mb-10">
<div className="inline-flex items-center gap-2 text-primary text-sm font-medium mb-4 bg-primary/10 px-3 py-1 rounded-full"> <div className="inline-flex items-center gap-2 text-primary text-sm font-medium mb-4 bg-primary/10 px-3 py-1 rounded-full">
@@ -278,6 +288,7 @@ export function ImportPage() {
</p> </p>
<AppFooter /> <AppFooter />
</div>
</div> </div>
) )
} }

View File

@@ -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<string, number>()
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 <span className="text-[11px] italic text-muted-foreground">Sin simular</span>
}
if (simulation.resultAutomated) {
const ahorro = (simulation.result.totalCost - simulation.resultAutomated.totalCost) * process.annualFrequency
return (
<div className="text-right">
<div className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium"
style={{ background: '#f0fdf4', border: '1px solid #86efac', color: '#15803d' }}>
<TrendingUp className="h-3 w-3" />
{formatCurrency(ahorro, process.currency)}
</div>
<p className="text-[10px] text-muted-foreground mt-0.5">Ahorro anual proyectado</p>
</div>
)
}
const costoAnual = simulation.result.totalCost * process.annualFrequency
return (
<div className="text-right">
<div className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium border border-border bg-secondary text-secondary-foreground">
<BarChart2 className="h-3 w-3" />
{formatCurrency(costoAnual, process.currency)}
</div>
<p className="text-[10px] text-muted-foreground mt-0.5">Costo anual actual</p>
</div>
)
}
// ─── Process Card ─────────────────────────────────────────────────────────────
function ProcessCard({ process, simulation, showGroupChip, groupName }: {
process: Process
simulation: Simulation | null | undefined
showGroupChip?: boolean
groupName?: string
}) {
const navigate = useNavigate()
return (
<div
className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card hover:border-border/80 hover:shadow-sm transition-all cursor-pointer"
onClick={() => navigate({ to: '/workspace/$processId', params: { processId: process.id } })}
>
{/* Ícono */}
<div className="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center"
style={{ background: '#fff3e6' }}>
<GitBranch className="h-4 w-4" style={{ color: '#F59845' }} />
</div>
{/* Info central */}
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium truncate">{process.name || 'Sin nombre'}</p>
<div className="flex items-center gap-1 mt-0.5">
<Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-[11px] text-muted-foreground">Modificado {relativeDate(process.updatedAt)}</span>
</div>
{(process.tags.length > 0 || showGroupChip) && (
<div className="flex flex-wrap gap-1 mt-1">
{showGroupChip && groupName && (
<span className="text-[10px] px-2 py-0.5 rounded-full border"
style={{ background: '#fff3e6', borderColor: '#F59845', color: '#7a3e00' }}>
{groupName}
</span>
)}
{process.tags.map((tag) => (
<span key={tag} className="text-[10px] px-2 py-0.5 rounded-full bg-secondary border border-border text-secondary-foreground">
{tag}
</span>
))}
</div>
)}
</div>
{/* KPI derecha */}
<div className="flex-shrink-0 min-w-[90px]">
<KpiBadge process={process} simulation={simulation} />
</div>
</div>
)
}
// ─── Group Card ───────────────────────────────────────────────────────────────
function GroupCard({ group, processCount, lastUpdated, onClick }: {
group: ProcessGroup
processCount: number
lastUpdated: number | null
onClick: () => void
}) {
return (
<button
className="text-left w-full rounded-xl p-3 transition-all"
style={{
background: 'hsl(var(--card))',
border: '0.5px solid hsl(var(--border))',
borderLeft: '3px solid #F59845',
borderRadius: '0.5rem',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = 'hsl(var(--border) / 0.8)'; (e.currentTarget as HTMLButtonElement).style.borderLeftColor = '#F59845' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = 'hsl(var(--border))'; (e.currentTarget as HTMLButtonElement).style.borderLeftColor = '#F59845' }}
onClick={onClick}
>
<p className="text-2xl font-medium" style={{ color: '#F59845' }}>{processCount}</p>
<p className="text-[13px] font-medium truncate mt-0.5">{group.name}</p>
{lastUpdated && (
<p className="text-[11px] text-muted-foreground mt-1">
Actualizado {relativeDate(lastUpdated)}
</p>
)}
</button>
)
}
// ─── 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<HTMLInputElement>(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 (
<div className="rounded-xl p-3 transition-all"
style={{ border: '1.5px solid #F59845', background: 'hsl(var(--card))', borderRadius: '0.5rem' }}>
<input
ref={inputRef}
value={value}
onChange={(e) => 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"
/>
<div className="flex gap-1.5 mt-2">
<button
onClick={confirm}
className="text-[11px] px-2 py-0.5 rounded font-medium text-white"
style={{ background: '#F59845' }}
>
Crear
</button>
<button
onClick={cancel}
className="text-[11px] px-2 py-0.5 rounded font-medium text-muted-foreground border border-border"
>
Cancelar
</button>
</div>
</div>
)
}
return (
<button
className="text-left w-full rounded-xl p-3 flex flex-col items-center justify-center gap-1.5 transition-all group"
style={{ border: '0.5px dashed hsl(var(--border))', background: 'hsl(var(--card))', borderRadius: '0.5rem', minHeight: '80px' }}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = '#F59845'; (e.currentTarget as HTMLButtonElement).style.color = '#F59845' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = 'hsl(var(--border))'; (e.currentTarget as HTMLButtonElement).style.color = '' }}
onClick={() => setEditing(true)}
>
<Plus className="h-4 w-4 text-muted-foreground group-hover:text-[#F59845] transition-colors" />
<span className="text-[12px] text-muted-foreground group-hover:text-[#F59845] transition-colors">
Nuevo {label.toLowerCase()}
</span>
</button>
)
}
// ─── 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<string[]>([])
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 (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<Building2 className="h-16 w-16 text-muted-foreground/30" />
<p className="text-base font-medium">Tu biblioteca está vacía</p>
<p className="text-[13px] text-muted-foreground">Importá tu primer BPMN para empezar</p>
<button
onClick={() => onImport()}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
style={{ background: '#F59845' }}
>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
</div>
)
}
return (
<div className="space-y-5">
{/* Búsqueda */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
value={search}
onChange={(e) => 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"
/>
</div>
{/* Resultados de búsqueda */}
{isSearching && (
<div className="space-y-2">
{/* Filtros activos */}
{activeTags.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px] text-muted-foreground">Filtros activos:</span>
{activeTags.map((tag) => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border"
style={{ background: '#fff3e6', borderColor: '#F59845', color: '#7a3e00' }}
>
{tag} <X className="h-2.5 w-2.5" />
</button>
))}
<button
onClick={() => setActiveTags([])}
className="text-[11px] text-muted-foreground hover:text-foreground"
>
Limpiar todo
</button>
</div>
)}
<p className="text-[11px] text-muted-foreground uppercase tracking-wide font-medium">
{filteredProcesses.length} resultado{filteredProcesses.length !== 1 ? 's' : ''}
</p>
{filteredProcesses.length === 0 && (
<p className="text-[13px] text-muted-foreground py-4 text-center">Sin resultados para esa búsqueda.</p>
)}
{filteredProcesses.map((p) => {
const group = groups.find((g) => g.id === p.groupId)
return (
<ProcessCardWithSim
key={p.id}
process={p}
showGroupChip
groupName={group?.name}
/>
)
})}
</div>
)}
{/* Vista normal — grupos */}
{!isSearching && (
<>
{/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */}
{cloud.length >= 3 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] text-muted-foreground shrink-0">Tags:</span>
{cloud.map(({ tag, fontSize }) => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className="px-2 py-0.5 rounded-full border transition-all hover:border-[#F59845] hover:bg-[#fff3e6] hover:text-[#7a3e00]"
style={{ fontSize, background: 'white', borderColor: 'hsl(var(--border))' }}
>
{tag}
</button>
))}
</div>
)}
{/* Tags simples cuando hay 1-2 */}
{cloud.length > 0 && cloud.length < 3 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[12px] text-muted-foreground">Tags:</span>
{cloud.map(({ tag }) => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className="text-[11px] px-2.5 py-0.5 rounded-full border transition-all"
style={{ background: 'white', borderColor: 'hsl(var(--border))' }}
>
{tag}
</button>
))}
</div>
)}
{/* Header sección grupos */}
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{settings.groupLabel}s
</span>
<span className="text-[10px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground">
{groups.length}
</span>
</div>
{/* Grid de grupos */}
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
{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 (
<GroupCard
key={group.id}
group={group}
processCount={groupProcesses.length}
lastUpdated={lastUpdated}
onClick={() => onNavigateToGroup(group.id)}
/>
)
})}
<NewGroupCard
label={settings.groupLabel}
onCreate={(name) => createGroup(name)}
/>
</div>
{/* Sin clasificar */}
{unclassifiedProcesses.length > 0 && (
<button
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all hover:border-border/80"
style={{ border: '0.5px dashed hsl(var(--border))', background: 'hsl(var(--card))', borderRadius: '0.5rem' }}
onClick={() => onNavigateToGroup(null)}
>
<FolderX className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[13px] font-medium text-secondary-foreground">
Procesos sin {settings.groupLabel.toLowerCase()} asignado
</span>
</div>
<span className="text-[11px] text-muted-foreground flex-shrink-0">
{unclassifiedProcesses.length} proceso{unclassifiedProcesses.length !== 1 ? 's' : ''}
</span>
</button>
)}
</>
)}
</div>
)
}
// ─── ProcessCardWithSim (carga simulación lazy) ───────────────────────────────
function ProcessCardWithSim({ process, showGroupChip, groupName }: {
process: Process
showGroupChip?: boolean
groupName?: string
}) {
const { getLatestSimulation } = useLibraryStore()
const [simulation, setSimulation] = useState<Simulation | null | undefined>(undefined)
useEffect(() => {
let cancelled = false
getLatestSimulation(process.id).then((sim) => {
if (!cancelled) setSimulation(sim ?? null)
})
return () => { cancelled = true }
}, [process.id, getLatestSimulation])
return <ProcessCard process={process} simulation={simulation} showGroupChip={showGroupChip} groupName={groupName} />
}
// ─── 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 (
<div className="space-y-5">
{/* Breadcrumb */}
<div className="flex items-center gap-2">
<button
onClick={onBack}
className="text-[13px] font-medium hover:opacity-80 transition-opacity"
style={{ color: '#F59845' }}
>
Biblioteca
</button>
<span className="text-muted-foreground text-[13px]">/</span>
<span className="text-[13px] font-medium">{groupName}</span>
</div>
<p className="text-[12px] text-muted-foreground -mt-3">
{groupProcesses.length} proceso{groupProcesses.length !== 1 ? 's' : ''}
</p>
{/* Búsqueda */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
value={search}
onChange={(e) => 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"
/>
</div>
{/* Empty state */}
{groupProcesses.length === 0 && !search && (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<FileX2 className="h-12 w-12 text-muted-foreground/30" />
<p className="text-[13px] font-medium text-secondary-foreground">
No hay procesos en este {settings.groupLabel.toLowerCase()}.
</p>
<p className="text-[13px] text-muted-foreground">Importá un BPMN para comenzar.</p>
</div>
)}
{/* Lista de procesos */}
<div className="space-y-2">
{groupProcesses.map((p) => (
<ProcessCardWithSim key={p.id} process={p} />
))}
</div>
</div>
)
}
// ─── 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<string | null>(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 (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-4xl mx-auto px-6 py-8">
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-[18px] font-medium">Biblioteca de procesos</h1>
<p className="text-[12px] text-muted-foreground mt-0.5">
{processes.length} proceso{processes.length !== 1 ? 's' : ''} · {totalGroups} {settings.groupLabel.toLowerCase()}{totalGroups !== 1 ? 's' : ''}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => navigate({ to: '/settings' })}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
title="Configuración"
>
<Settings className="h-4 w-4" />
</button>
<button
onClick={() => handleImport(view === 'group' && activeGroupId ? activeGroupId : undefined)}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
style={{ background: '#F59845' }}
>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
</div>
</div>
{/* Contenido con transición */}
{isLoading ? (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
) : (
<div
className="transition-all duration-150"
style={{ opacity: transitioning ? 0 : 1, transform: transitioning ? 'translateY(6px)' : 'translateY(0)' }}
>
{view === 'home' ? (
<HomeView
onNavigateToGroup={navigateToGroup}
onImport={handleImport}
/>
) : (
<GroupView
groupId={activeGroupId}
onBack={navigateHome}
/>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -10,6 +10,7 @@ type SortKey = 'activityName' | 'expectedDirectCost' | 'expectedIndirectCost' |
interface ActivitiesTableProps { interface ActivitiesTableProps {
perActivity: ActivitySimResult[] perActivity: ActivitySimResult[]
pairedBreakdown?: Record<string, Array<{ resourceId: string; resourceName: string; cost: number }>>
mode: HeatmapMode mode: HeatmapMode
currency: string currency: string
highlightedId: string | null highlightedId: string | null
@@ -23,7 +24,7 @@ function SortIcon({ column, sortKey, dir }: { column: SortKey; sortKey: SortKey;
: <ArrowDown className="h-3 w-3 ml-1 text-primary" /> : <ArrowDown className="h-3 w-3 ml-1 text-primary" />
} }
export function ActivitiesTable({ perActivity, mode, currency, highlightedId, onRowClick }: ActivitiesTableProps) { export function ActivitiesTable({ perActivity, pairedBreakdown, mode, currency, highlightedId, onRowClick }: ActivitiesTableProps) {
const [sortKey, setSortKey] = useState<SortKey>('expectedTotalCost') const [sortKey, setSortKey] = useState<SortKey>('expectedTotalCost')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set()) const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
@@ -167,23 +168,50 @@ export function ActivitiesTable({ perActivity, mode, currency, highlightedId, on
<TableCell /> <TableCell />
<TableCell colSpan={7} className="py-2 pl-4 pr-6"> <TableCell colSpan={7} className="py-2 pl-4 pr-6">
<div className="space-y-1"> <div className="space-y-1">
{fixedCostExpected > 0 && ( {pairedBreakdown?.[act.activityId] ? (
<div className="flex justify-between text-xs text-slate-500"> <>
<span>Fijo (insumos, licencias)</span> <p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mt-1">AS-IS</p>
<span className="font-mono">{formatCurrency(fixedCostExpected, currency)}</span> {pairedBreakdown[act.activityId].map((r) => (
</div> <div key={r.resourceId} className="flex justify-between text-xs">
)} <span className="text-slate-600">{r.resourceName}</span>
{act.resourceCostBreakdown.map((r) => ( <span className="font-mono">{formatCurrency(r.cost, currency)}</span>
<div key={r.resourceId} className="flex justify-between text-xs text-slate-500"> </div>
<span>{r.resourceName}</span> ))}
<span className="font-mono">{formatCurrency(r.cost, currency)}</span> {pairedBreakdown[act.activityId].length === 0 && (
</div> <p className="text-xs text-slate-400 italic">Sin recursos AS-IS</p>
))} )}
{act.resourceCostBreakdown.length === 0 && fixedCostExpected === 0 && ( <p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mt-2">TO-BE</p>
<p className="text-xs text-slate-400">Sin desglose disponible</p> {act.resourceCostBreakdown.length === 0
)} ? <p className="text-xs text-slate-400 italic">Sin recursos TO-BE</p>
{act.resourceCostBreakdown.length === 0 && fixedCostExpected > 0 && ( : act.resourceCostBreakdown.map((r) => (
<p className="text-xs text-slate-400 mt-0.5">Sin recursos asignados costo es 100% fijo</p> <div key={r.resourceId} className="flex justify-between text-xs">
<span className="text-slate-600">{r.resourceName}</span>
<span className="font-mono">{formatCurrency(r.cost, currency)}</span>
</div>
))
}
</>
) : (
<>
{fixedCostExpected > 0 && (
<div className="flex justify-between text-xs text-slate-500">
<span>Fijo (insumos, licencias)</span>
<span className="font-mono">{formatCurrency(fixedCostExpected, currency)}</span>
</div>
)}
{act.resourceCostBreakdown.map((r) => (
<div key={r.resourceId} className="flex justify-between text-xs text-slate-500">
<span>{r.resourceName}</span>
<span className="font-mono">{formatCurrency(r.cost, currency)}</span>
</div>
))}
{act.resourceCostBreakdown.length === 0 && fixedCostExpected === 0 && (
<p className="text-xs text-slate-400">Sin desglose disponible</p>
)}
{act.resourceCostBreakdown.length === 0 && fixedCostExpected > 0 && (
<p className="text-xs text-slate-400 mt-0.5">Sin recursos asignados costo es 100% fijo</p>
)}
</>
)} )}
</div> </div>
</TableCell> </TableCell>

View File

@@ -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 { useParams, useNavigate } from '@tanstack/react-router'
import { AlertTriangle, Home, Info } from 'lucide-react' import { AlertTriangle, Home, Info } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -27,6 +27,8 @@ import { TransparencySection } from './TransparencySection'
import { AutomationScenarioNote } from './AutomationScenarioNote' import { AutomationScenarioNote } from './AutomationScenarioNote'
import { MethodologyFooter } from './MethodologyFooter' import { MethodologyFooter } from './MethodologyFooter'
import type { HeatmapMode } from '@/lib/colors' import type { HeatmapMode } from '@/lib/colors'
import { useSimulationStore } from '@/store/simulation-store'
import { AppHeader } from '@/components/AppHeader'
export function ReportPage() { export function ReportPage() {
const { processId } = useParams({ from: '/report/$processId' }) const { processId } = useParams({ from: '/report/$processId' })
@@ -35,6 +37,12 @@ export function ReportPage() {
const { toast } = useToast() const { toast } = useToast()
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>('relative') const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>('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 // Set de bpmnElementIds marcados como automatizables — debe estar antes de los early returns
const automatableIds = useMemo( const automatableIds = useMemo(
@@ -56,6 +64,14 @@ export function ReportPage() {
}) })
}, [simulation, process]) }, [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" // Estado y refs para el tab "Actual"
const [selectedIdActual, setSelectedIdActual] = useState<string | null>(null) const [selectedIdActual, setSelectedIdActual] = useState<string | null>(null)
const heatmapRef = useRef<HeatmapCanvasHandle>(null) const heatmapRef = useRef<HeatmapCanvasHandle>(null)
@@ -219,9 +235,29 @@ export function ReportPage() {
// marcadas como automatizables. Sin automatizables, los tabs muestran datos idénticos al actual. // marcadas como automatizables. Sin automatizables, los tabs muestran datos idénticos al actual.
const hasAutomatedScenario = hasAutomatedResult && automatableIds.size > 0 const hasAutomatedScenario = hasAutomatedResult && automatableIds.size > 0
const isStale = Boolean(process && process.updatedAt > (lastPersistedExecutedAt ?? 0))
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="min-h-screen bg-slate-50"> <div className="min-h-screen bg-slate-50">
<AppHeader />
{/* Banner stale — cambios sin simular */}
{isStale && (
<div className="flex items-center justify-between px-6 py-2.5 text-[12px] font-medium"
style={{ background: '#fffbeb', borderBottom: '1px solid #f59e0b', color: '#92400e' }}>
<div className="flex items-center gap-2">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
Los resultados pueden estar desactualizados hay cambios sin simular.
</div>
<button
onClick={() => navigate({ to: '/workspace/$processId', params: { processId } })}
className="underline hover:no-underline shrink-0 ml-4"
>
Ir al workspace
</button>
</div>
)}
<div className="max-w-screen-xl mx-auto px-6 py-10"> <div className="max-w-screen-xl mx-auto px-6 py-10">
{/* ── Header (fuera de los tabs: aplica a todos los escenarios) ──── */} {/* ── Header (fuera de los tabs: aplica a todos los escenarios) ──── */}
@@ -449,6 +485,7 @@ export function ReportPage() {
</div> </div>
<ActivitiesTable <ActivitiesTable
perActivity={resultAutomated!.perActivity} perActivity={resultAutomated!.perActivity}
pairedBreakdown={actualBreakdownById}
mode={heatmapMode} mode={heatmapMode}
currency={process.currency} currency={process.currency}
highlightedId={selectedIdAuto} highlightedId={selectedIdAuto}

View File

@@ -58,9 +58,11 @@ function RoiKpiCard({
export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) { export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
const paybackStr = formatPayback(roi.paybackMonths) const paybackStr = formatPayback(roi.paybackMonths)
const roiStr = !isFinite(roi.roiAnnualPercent) const roiIsUndefined = !isFinite(roi.roiAnnualPercent)
? '∞%' const roiStr = roiIsUndefined ? '—' : formatPercent(roi.roiAnnualPercent)
: formatPercent(roi.roiAnnualPercent) const roiSub = roiIsUndefined
? 'Configurá la inversión del proyecto en Configuración Global'
: 'retorno anual sobre la inversión'
const savingsPosColor = roi.savingsPerExecution > 0 const savingsPosColor = roi.savingsPerExecution > 0
? 'text-emerald-700' ? 'text-emerald-700'
@@ -117,10 +119,10 @@ export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
<RoiKpiCard <RoiKpiCard
label="ROI anual" label="ROI anual"
value={roiStr} value={roiStr}
sub="retorno anual sobre la inversión" sub={roiSub}
icon={<Percent className="h-4 w-4 text-amber-600" />} icon={<Percent className="h-4 w-4 text-amber-600" />}
iconColor="bg-amber-50" 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'}
/> />
</div> </div>
</div> </div>

View File

@@ -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<HTMLInputElement>(null)
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-2xl mx-auto px-6 py-8">
{/* Header */}
<div className="mb-8">
<button
onClick={() => navigate({ to: '/' })}
className="text-sm font-medium mb-4 inline-block transition-opacity hover:opacity-80"
style={{ color: '#F59845' }}
>
Volver
</button>
<h1 className="text-[18px] font-medium">Configuración</h1>
<p className="text-[12px] text-muted-foreground mt-0.5">Personalización del workspace</p>
</div>
{/* Sección Biblioteca */}
<div className="space-y-5">
<div className="border-b border-border pb-2">
<h2 className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Biblioteca
</h2>
</div>
<div className="space-y-2">
<label className="text-[13px] font-medium block">Etiqueta de grupos</label>
<p className="text-[12px] text-muted-foreground">
Cómo se llama el nivel de agrupación en la biblioteca. Por defecto: 'Cliente'.
</p>
<input
ref={inputRef}
type="text"
value={localLabel}
onChange={(e) => { 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() && (
<p className="text-[12px] text-muted-foreground">
Los procesos se organizarán por <strong>{localLabel.trim()}</strong>.
</p>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' 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 { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -26,7 +26,11 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
// Sincronizar con el store cuando cambia la selección // Sincronizar con el store cuando cambia la selección
useEffect(() => { useEffect(() => {
setLocalActivity(activity ? { ...activity, assignedResources: [...activity.assignedResources] } : null) setLocalActivity(activity ? {
...activity,
assignedResources: [...activity.assignedResources],
automatedAssignedResources: [...(activity.automatedAssignedResources ?? [])],
} : null)
setIsDirty(false) setIsDirty(false)
}, [selectedElementId, activities]) }, [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) { if (!selectedElementId) {
return ( return (
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-12"> <div className="flex flex-col items-center justify-center h-full text-center px-6 py-12">
@@ -119,6 +153,12 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
const localTotalCost = localActivity.directCostFixed + localResourceCost const localTotalCost = localActivity.directCostFixed + localResourceCost
const availableAutomatedResources = resources.filter(
(r) => !(localActivity.automatedAssignedResources ?? []).some((ar) => ar.resourceId === r.id)
)
const currency = currentProcess?.currency ?? 'USD'
return ( return (
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<div className="p-4 space-y-5"> <div className="p-4 space-y-5">
@@ -370,7 +410,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
</div> </div>
{/* Campos — aparecen con transición suave cuando toggle=ON */} {/* Campos — aparecen con transición suave cuando toggle=ON */}
<div className={`space-y-3 overflow-hidden transition-all duration-200 ${localActivity.automatable ? 'max-h-80 opacity-100' : 'max-h-0 opacity-0'}`}> <div className={`space-y-3 overflow-hidden transition-all duration-200 ${localActivity.automatable ? 'max-h-[640px] opacity-100' : 'max-h-0 opacity-0'}`}>
{/* Costo automatizado */} {/* Costo automatizado */}
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -417,6 +457,101 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
/> />
</div> </div>
{/* ── Recursos TO-BE ───────────────────────────────────────── */}
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
Recursos TO-BE
</p>
{availableAutomatedResources.length > 0 && (
<Select onValueChange={addAutomatedResource}>
<SelectTrigger className="h-7 text-xs w-[120px]">
<SelectValue placeholder="+ Agregar" />
</SelectTrigger>
<SelectContent>
{availableAutomatedResources.map((r) => (
<SelectItem key={r.id} value={r.id} className="text-xs">
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{(localActivity.automatedAssignedResources ?? []).length === 0 ? (
<p className="text-xs text-slate-400 italic">
Sin recursos TO-BE solo costo fijo automatizado.
</p>
) : (
<div className="space-y-2">
{(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 (
<div key={assignment.resourceId} className="rounded-lg border border-border bg-card p-2 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium truncate max-w-[120px]">{resource.name}</span>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-slate-500">
{formatCurrency(cost, currency)}
</span>
<button
type="button"
onClick={() => removeAutomatedResource(assignment.resourceId)}
className="text-slate-400 hover:text-red-500 transition-colors"
aria-label={`Quitar ${resource.name}`}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 space-y-0.5">
<p className="text-[10px] text-slate-400">Min. por ejecución</p>
<Input
type="number"
min={0}
step={1}
value={assignment.utilizationMinutes || ''}
onChange={(e) =>
updateAutomatedAssignment(
assignment.resourceId,
'utilizationMinutes',
Math.max(0, parseFloat(e.target.value) || 0)
)
}
className="h-6 text-xs font-mono"
/>
</div>
<div className="w-16 space-y-0.5">
<p className="text-[10px] text-slate-400">Unidades</p>
<Input
type="number"
min={1}
step={1}
value={assignment.units || ''}
onChange={(e) =>
updateAutomatedAssignment(
assignment.resourceId,
'units',
Math.max(1, parseInt(e.target.value) || 1)
)
}
className="h-6 text-xs font-mono"
/>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
{/* Helper ámbar — siempre en el DOM para que la transición funcione {/* Helper ámbar — siempre en el DOM para que la transición funcione
al tipear un valor (no desaparece instantáneo sino con fade). al tipear un valor (no desaparece instantáneo sino con fade).
La condición controla opacity + max-h, no mount/unmount. */} La condición controla opacity + max-h, no mount/unmount. */}
@@ -424,6 +559,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
const showHelper = localActivity.automatable const showHelper = localActivity.automatable
&& localActivity.automatedCostFixed === 0 && localActivity.automatedCostFixed === 0
&& localActivity.automatedTimeMinutes === 0 && localActivity.automatedTimeMinutes === 0
&& (localActivity.automatedAssignedResources ?? []).length === 0
return ( return (
<div <div
aria-hidden={!showHelper} aria-hidden={!showHelper}

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useCallback } from 'react' import { useEffect, useRef, useCallback } from 'react'
import BpmnViewer from 'bpmn-js/lib/Viewer' import BpmnViewer from 'bpmn-js/lib/Viewer'
import { Maximize2 } from 'lucide-react'
export type BpmnElementCategory = 'activity' | 'gateway' | 'other' export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
@@ -152,5 +153,34 @@ export function BpmnCanvas({
} }
}, [selectedElementId]) }, [selectedElementId])
return <div ref={containerRef} className={className} style={{ width: '100%', height: '100%' }} /> return (
<div className={className} style={{ position: 'relative', width: '100%', height: '100%' }}>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
<button
onClick={() => {
const canvas = viewerRef.current?.get('canvas') as any
canvas?.zoom('fit-viewport')
}}
title="Encuadrar diagrama"
style={{
position: 'absolute',
top: 12,
right: 12,
width: 32,
height: 32,
borderRadius: 6,
background: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
zIndex: 10,
}}
>
<Maximize2 className="h-4 w-4 text-muted-foreground" />
</button>
</div>
)
} }

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, useCallback, useMemo, useDeferredValue } from 'react' import { useEffect, useRef, useState, useCallback, useMemo, useDeferredValue } from 'react'
import { useParams, useNavigate } from '@tanstack/react-router' import { useParams, useNavigate } from '@tanstack/react-router'
import { import {
Loader2, BarChart3, Play, AlertTriangle, Loader2, BarChart3, Play, AlertTriangle, AlertCircle,
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2, PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2,
Check, Pencil Check, Pencil
} from 'lucide-react' } from 'lucide-react'
@@ -18,6 +18,8 @@ import { GlobalSettingsPanel } from './GlobalSettingsPanel'
import { useProcessStore } from '@/store/process-store' import { useProcessStore } from '@/store/process-store'
import { useSimulationStore } from '@/store/simulation-store' import { useSimulationStore } from '@/store/simulation-store'
import { useSimulate } from '../simulation/useSimulate' import { useSimulate } from '../simulation/useSimulate'
import { useLibraryStore } from '@/store/library-store'
import { AppHeader } from '@/components/AppHeader'
type SelectedCategory = 'activity' | 'gateway' | null type SelectedCategory = 'activity' | 'gateway' | null
@@ -25,7 +27,7 @@ export function WorkspacePage() {
const { processId } = useParams({ from: '/workspace/$processId' }) const { processId } = useParams({ from: '/workspace/$processId' })
const navigate = useNavigate() const navigate = useNavigate()
const { currentProcess, activities, isLoading, error, loadProcess, gateways, updateProcess } = useProcessStore() 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 { simulate } = useSimulate()
const { toast } = useToast() const { toast } = useToast()
@@ -41,6 +43,11 @@ export function WorkspacePage() {
const [savedField, setSavedField] = useState<'name' | 'client' | null>(null) const [savedField, setSavedField] = useState<'name' | 'client' | null>(null)
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const savedTimerRef = useRef<ReturnType<typeof setTimeout> | 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. // 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 // Esto evita que el redirect se dispare en el render inicial donde
// isLoading=false y currentProcess=null (antes del primer efecto). // isLoading=false y currentProcess=null (antes del primer efecto).
@@ -48,6 +55,11 @@ export function WorkspacePage() {
useEffect(() => { loadProcess(processId) }, [processId, loadProcess]) 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(() => { useEffect(() => {
if (isLoading) { hasLoadedOnce.current = true; return } if (isLoading) { hasLoadedOnce.current = true; return }
if (!hasLoadedOnce.current) return // carga no iniciada aún if (!hasLoadedOnce.current) return // carga no iniciada aún
@@ -71,6 +83,12 @@ export function WorkspacePage() {
const canSimulate = !isRunning && xorValidation.valid 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. // IDs de actividades marcadas como automatizables — alimenta los overlays del canvas.
// useDeferredValue evita re-renders en cascada mientras el usuario edita el panel. // useDeferredValue evita re-renders en cascada mientras el usuario edita el panel.
const automatableElementIds = useMemo( const automatableElementIds = useMemo(
@@ -118,6 +136,26 @@ export function WorkspacePage() {
savedTimerRef.current = setTimeout(() => setSavedField(null), 1500) 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<HTMLInputElement>) {
if (e.key === 'Enter') { e.preventDefault(); if (tagInput.trim()) addTag(tagInput) }
if (e.key === 'Escape') { setTagInput(''); setShowTagSuggestions(false) }
}
async function handleSimulate() { async function handleSimulate() {
if (!canSimulate) return if (!canSimulate) return
const simResult = await simulate() const simResult = await simulate()
@@ -147,9 +185,10 @@ export function WorkspacePage() {
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="h-screen flex flex-col bg-slate-100 overflow-hidden"> <div className="h-screen flex flex-col bg-slate-100 overflow-hidden">
<AppHeader />
{/* ── Topbar ─────────────────────────────────────────────────── */} {/* ── Topbar ─────────────────────────────────────────────────── */}
<header className="h-12 bg-white border-b border-slate-200 flex items-center px-3 gap-3 shrink-0 z-10"> <header className="min-h-12 bg-white border-b border-slate-200 flex items-center px-3 gap-3 shrink-0 z-10">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navigate({ to: '/' })}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navigate({ to: '/' })}>
@@ -219,6 +258,59 @@ export function WorkspacePage() {
</div> </div>
)} )}
{/* 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 (
<div className="flex flex-wrap items-center gap-1 mt-0.5">
{processTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full bg-secondary border border-border text-secondary-foreground"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-0.5 text-muted-foreground hover:text-foreground leading-none"
>
×
</button>
</span>
))}
<div className="relative">
<input
type="text"
value={tagInput}
onChange={(e) => { 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 && (
<div className="absolute top-full left-0 mt-1 bg-background border border-border rounded-md shadow-sm z-50 min-w-[120px]">
{suggestions.map((s) => (
<button
key={s}
type="button"
className="w-full text-left px-2 py-1 text-[11px] hover:bg-secondary transition-colors"
onMouseDown={(e) => { e.preventDefault(); addTag(s) }}
>
{s}
</button>
))}
</div>
)}
</div>
</div>
)
})()}
</div> </div>
{/* Errores de simulación */} {/* Errores de simulación */}
@@ -261,6 +353,15 @@ export function WorkspacePage() {
</Button> </Button>
)} )}
{/* Indicador stale */}
{isStale && (
<div className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium"
style={{ background: '#fff3e6', border: '1px solid #F59845', color: '#7a3e00' }}>
<AlertCircle className="h-3 w-3 shrink-0" />
Hay cambios sin simular
</div>
)}
{/* Simular */} {/* Simular */}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@@ -144,7 +144,7 @@ async function exportScenarioPdf(
if (hasResources) { if (hasResources) {
docAny.addPage('a4', 'portrait') docAny.addPage('a4', 'portrait')
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 4 }) 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 ────────────────────────────────────────── // ── Página 4 o 5: Nota metodológica ──────────────────────────────────────────

View File

@@ -485,7 +485,8 @@ export function drawResourcesSection(
resources: Resource[], resources: Resource[],
activities: Activity[], activities: Activity[],
currency: string, currency: string,
startY: number startY: number,
scenario: 'actual' | 'automatizado' = 'actual'
): void { ): void {
let y = startY let y = startY
const m = PDF.margin const m = PDF.margin
@@ -544,7 +545,9 @@ export function drawResourcesSection(
for (const br of act.resourceCostBreakdown) { for (const br of act.resourceCostBreakdown) {
const resource = resourceMap.get(br.resourceId) 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 typeName = resource ? (TYPE_LABEL[resource.type] ?? resource.type) : '—'
const typeColor = resource ? (TYPE_COLOR[resource.type] ?? PDF.slate50) : PDF.slate50 const typeColor = resource ? (TYPE_COLOR[resource.type] ?? PDF.slate50) : PDF.slate50

View File

@@ -1,5 +1,5 @@
import Dexie, { type Table } from 'dexie' 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 { export class ProcessCostDb extends Dexie {
processes!: Table<Process> processes!: Table<Process>
@@ -7,6 +7,8 @@ export class ProcessCostDb extends Dexie {
gateways!: Table<GatewayConfig> gateways!: Table<GatewayConfig>
resources!: Table<Resource> resources!: Table<Resource>
simulations!: Table<Simulation> simulations!: Table<Simulation>
groups!: Table<ProcessGroup> // 🆕 Sprint 3
settings!: Table<AppSettings> // 🆕 Sprint 3
constructor() { constructor() {
super('ProcessCostPlatform') 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' })
}
})
} }
} }

View File

@@ -17,7 +17,7 @@
*/ */
import { db } from './db' 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 ───────────────────────────────────────────────────────────────── // ─── Process ─────────────────────────────────────────────────────────────────
@@ -125,13 +125,56 @@ export const simulationRepo = {
}, },
async getLatestByProcess(processId: string): Promise<Simulation | undefined> { async getLatestByProcess(processId: string): Promise<Simulation | undefined> {
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') .where('processId')
.equals(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<Simulation[]> { async getByProcess(processId: string): Promise<Simulation[]> {
return db.simulations.where('processId').equals(processId).toArray() 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<void> {
await db.groups.put(group)
},
async getAll(): Promise<ProcessGroup[]> {
return db.groups.orderBy('name').toArray()
},
async getById(id: string): Promise<ProcessGroup | undefined> {
return db.groups.get(id)
},
async delete(id: string): Promise<void> {
// 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<AppSettings> {
const s = await db.settings.get('singleton')
return s ?? { id: 'singleton', groupLabel: 'Cliente' }
},
async update(updates: Partial<Omit<AppSettings, 'id'>>): Promise<void> {
await db.settings.update('singleton', updates)
},
}

View File

@@ -2,6 +2,8 @@ import { lazy, Suspense } from 'react'
import { createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/react-router' import { createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/react-router'
import { WorkspacePage } from '@/features/workspace/WorkspacePage' import { WorkspacePage } from '@/features/workspace/WorkspacePage'
import { ImportPage } from '@/features/import/ImportPage' import { ImportPage } from '@/features/import/ImportPage'
import { LibraryPage } from '@/features/library/LibraryPage'
import { SettingsPage } from '@/features/settings/SettingsPage'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
// ECharts es grande (373KB gzip). Lazy-load del ReportPage para que el // ECharts es grande (373KB gzip). Lazy-load del ReportPage para que el
@@ -28,6 +30,12 @@ const rootRoute = createRootRoute({ component: () => <Outlet /> })
const indexRoute = createRoute({ const indexRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/', path: '/',
component: LibraryPage,
})
const importRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/import',
component: ImportPage, component: ImportPage,
}) })
@@ -43,7 +51,13 @@ const reportRoute = createRoute({
component: ReportPageWithSuspense, 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 }) export const router = createRouter({ routeTree })
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {

View File

@@ -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<void>
createGroup: (name: string) => Promise<ProcessGroup>
deleteGroup: (id: string) => Promise<void>
assignProcessToGroup: (processId: string, groupId: string | null) => Promise<void>
getProcessesByGroup: (groupId: string | null) => Process[]
getLatestSimulation: (processId: string) => Promise<Simulation | undefined>
updateSettings: (updates: Partial<Omit<AppSettings, 'id'>>) => Promise<void>
getAllTags: () => string[]
}
const DEFAULT_SETTINGS: AppSettings = { id: 'singleton', groupLabel: 'Cliente' }
export const useLibraryStore = create<LibraryState>((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<string>()
get().processes.forEach((p) => p.tags.forEach((t) => tagSet.add(t)))
return [...tagSet].sort()
},
}))

View File

@@ -66,33 +66,80 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
updateActivity: async (activity) => { updateActivity: async (activity) => {
await activityRepo.save(activity) await activityRepo.save(activity)
set((state) => ({ const current = get().currentProcess
activities: state.activities.map((a) => (a.id === activity.id ? activity : a)), 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) => { updateGateway: async (gateway) => {
await gatewayRepo.save(gateway) await gatewayRepo.save(gateway)
set((state) => ({ const current = get().currentProcess
gateways: state.gateways.map((g) => (g.id === gateway.id ? gateway : g)), 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) => { addResource: async (resource) => {
await resourceRepo.save(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) => { updateResource: async (resource) => {
await resourceRepo.save(resource) await resourceRepo.save(resource)
set((state) => ({ const current = get().currentProcess
resources: state.resources.map((r) => (r.id === resource.id ? resource : r)), 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) => { deleteResource: async (resourceId) => {
await resourceRepo.delete(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 }), reset: () => set({ currentProcess: null, activities: [], gateways: [], resources: [], error: null }),

View File

@@ -11,6 +11,7 @@ interface SimulationState {
resultActual: SimulationResult | null // resultado del escenario actual resultActual: SimulationResult | null // resultado del escenario actual
resultAutomated: SimulationResult | null // resultado del escenario automatizado resultAutomated: SimulationResult | null // resultado del escenario automatizado
lastSimulatedAt: Date | null // timestamp de la última simulación exitosa 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 isRunning: boolean
error: string | null error: string | null
selectedActivityId: string | null selectedActivityId: string | null
@@ -24,25 +25,34 @@ interface SimulationState {
// Invalida AMBOS resultados a null. Se llama cuando cambian datos del proceso. // Invalida AMBOS resultados a null. Se llama cuando cambian datos del proceso.
invalidateResults: () => void invalidateResults: () => void
reset: () => void reset: () => void
loadLatestForProcess: (processId: string) => Promise<void>
} }
export const useSimulationStore = create<SimulationState>((set) => ({ export const useSimulationStore = create<SimulationState>((set) => ({
resultActual: null, resultActual: null,
resultAutomated: null, resultAutomated: null,
lastSimulatedAt: null, lastSimulatedAt: null,
lastPersistedExecutedAt: null,
isRunning: false, isRunning: false,
error: null, error: null,
selectedActivityId: null, selectedActivityId: null,
persistResults: async (processId, actual, automated) => { persistResults: async (processId, actual, automated) => {
const executedAt = Date.now()
await simulationRepo.save({ await simulationRepo.save({
id: uuidv4(), id: uuidv4(),
processId, processId,
executedAt: Date.now(), executedAt,
result: actual, result: actual,
resultAutomated: automated, 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 }), setRunning: (isRunning) => set({ isRunning }),
@@ -57,10 +67,16 @@ export const useSimulationStore = create<SimulationState>((set) => ({
resultActual: null, resultActual: null,
resultAutomated: null, resultAutomated: null,
lastSimulatedAt: null, lastSimulatedAt: null,
lastPersistedExecutedAt: null,
isRunning: false, isRunning: false,
error: null, error: null,
selectedActivityId: 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 // Suscripción reactiva: cualquier cambio en activities o currentProcess del process-store

View File

@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
<process id="proc1" isExecutable="false">
<startEvent id="start"><outgoing>f1</outgoing></startEvent>
<task id="taskA" name="Tarea A"><incoming>f1</incoming><outgoing>f2</outgoing></task>
<endEvent id="end"><incoming>f2</incoming></endEvent>
<sequenceFlow id="f1" sourceRef="start" targetRef="taskA"/>
<sequenceFlow id="f2" sourceRef="taskA" targetRef="end"/>
</process>
</definitions>`
const makeResource = (id: string, costPerHour: number): Resource => ({
id, processId: 'proc1', name: `Recurso ${id}`, type: 'role', costPerHour,
})
const baseActivity = (overrides: Partial<Activity> = {}): 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)
})
})

View File

@@ -92,6 +92,7 @@ describe('runSimulation', () => {
automatable: false, automatable: false,
automatedCostFixed: 0, automatedCostFixed: 0,
automatedTimeMinutes: 0, automatedTimeMinutes: 0,
automatedAssignedResources: [],
}) })
it('calcula costo total sin recursos — proceso lineal', () => { it('calcula costo total sin recursos — proceso lineal', () => {
@@ -220,6 +221,7 @@ describe('runSimulation — scenario automated', () => {
opts: { opts: {
directCost?: number; directTime?: number directCost?: number; directTime?: number
automatable?: boolean; automatedCost?: number; automatedTime?: number automatable?: boolean; automatedCost?: number; automatedTime?: number
automatedAssignedResources?: import('@/domain/types').ActivityResourceAssignment[]
} = {} } = {}
): Activity => ({ ): Activity => ({
id: `act-${bpmnElementId}`, id: `act-${bpmnElementId}`,
@@ -233,6 +235,7 @@ describe('runSimulation — scenario automated', () => {
automatable: opts.automatable ?? false, automatable: opts.automatable ?? false,
automatedCostFixed: opts.automatedCost ?? 0, automatedCostFixed: opts.automatedCost ?? 0,
automatedTimeMinutes: opts.automatedTime ?? 0, automatedTimeMinutes: opts.automatedTime ?? 0,
automatedAssignedResources: opts.automatedAssignedResources ?? [],
}) })
// ─── scenario=automated con costos en cero ──────────────────────────────── // ─── scenario=automated con costos en cero ────────────────────────────────
@@ -409,6 +412,7 @@ describe('runSimulation — recursos con modelo utilizationMinutes + units', ()
directCostFixed: directCost, executionTimeMinutes: minutes, directCostFixed: directCost, executionTimeMinutes: minutes,
assignedResources: resources, assignedResources: resources,
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
automatedAssignedResources: [],
}) })
it('actividad sin recursos: costo = directCostFixed', () => { it('actividad sin recursos: costo = directCostFixed', () => {

View File

@@ -227,7 +227,7 @@ describe('MethodologyFooter', () => {
id: 'p1', name: 'Proceso Test', clientName: '', id: 'p1', name: 'Proceso Test', clientName: '',
bpmnXml: '<def/>', currency: 'USD', bpmnXml: '<def/>', currency: 'USD',
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0, groupId: null, tags: [],
} }
expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow() expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow()
}) })
@@ -237,7 +237,7 @@ describe('MethodologyFooter', () => {
id: 'p1', name: 'Proc', clientName: '', id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD', bpmnXml: '', currency: 'USD',
overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0, groupId: null, tags: [],
} }
render(<MethodologyFooter process={proc} />) render(<MethodologyFooter process={proc} />)
const text = document.body.textContent ?? '' const text = document.body.textContent ?? ''
@@ -249,7 +249,7 @@ describe('MethodologyFooter', () => {
id: 'p1', name: 'Proc', clientName: '', id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD', bpmnXml: '', currency: 'USD',
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0, groupId: null, tags: [],
} }
render(<MethodologyFooter process={proc} />) render(<MethodologyFooter process={proc} />)
const text = document.body.textContent ?? '' const text = document.body.textContent ?? ''
@@ -334,6 +334,8 @@ describe('ReportPage — con datos completos', () => {
automationInvestment: 0, automationInvestment: 0,
createdAt: Date.now() - 1000 * 60 * 30, createdAt: Date.now() - 1000 * 60 * 30,
updatedAt: Date.now() - 1000 * 60 * 30, updatedAt: Date.now() - 1000 * 60 * 30,
groupId: null,
tags: [],
} }
const mockSimulation = { const mockSimulation = {

View File

@@ -31,7 +31,7 @@ function buildSimInput(xml: string, directCost: number, overhead: number, curren
directCostFixed: directCost, directCostFixed: directCost,
executionTimeMinutes: 30, executionTimeMinutes: 30,
assignedResources: [], assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
}]) }])
) )

View File

@@ -157,9 +157,10 @@ describe('RoiKpiBar', () => {
expect(screen.getByText(/Inmediato/i)).toBeInTheDocument() expect(screen.getByText(/Inmediato/i)).toBeInTheDocument()
}) })
it('ROI Infinity (inversión cero) → muestra "∞%"', () => { it('ROI Infinity (inversión cero) → muestra "—" con texto accionable', () => {
render(<RoiKpiBar roi={roiZeroInvestment} currency="USD" horizonYears={3} />) render(<RoiKpiBar roi={roiZeroInvestment} currency="USD" horizonYears={3} />)
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', () => { it('ahorro negativo: "breaksEvenInHorizon = false" → subtexto fuera del horizonte', () => {
@@ -311,6 +312,7 @@ const makeActivityDomain = (id: string, name: string, automatable: boolean, zero
automatable, automatable,
automatedCostFixed: zeroCosts ? 0 : 50, automatedCostFixed: zeroCosts ? 0 : 50,
automatedTimeMinutes: zeroCosts ? 0 : 10, automatedTimeMinutes: zeroCosts ? 0 : 10,
automatedAssignedResources: [],
}) })
describe('TransparencySection', () => { describe('TransparencySection', () => {
@@ -586,6 +588,7 @@ const mockProcessBase = {
currency: 'USD', overheadPercentage: 0.2, currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000, createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000,
groupId: null, tags: [],
} }
const mockSimulationBase = { const mockSimulationBase = {
@@ -598,6 +601,7 @@ const automtableActivity: Activity = {
id: 'act-1', processId: 'test-proc-id', bpmnElementId: 'bpmn_a1', name: 'Recibir solicitud', id: 'act-1', processId: 'test-proc-id', bpmnElementId: 'bpmn_a1', name: 'Recibir solicitud',
type: 'task', directCostFixed: 500, executionTimeMinutes: 60, assignedResources: [], type: 'task', directCostFixed: 500, executionTimeMinutes: 60, assignedResources: [],
automatable: true, automatedCostFixed: 50, automatedTimeMinutes: 5, automatable: true, automatedCostFixed: 50, automatedTimeMinutes: 5,
automatedAssignedResources: [],
} }
const nonAutomatableActivity: Activity = { const nonAutomatableActivity: Activity = {

View File

@@ -32,6 +32,7 @@ function makeActivity(overrides: Partial<Activity> & { id: string; processId: st
automatable: false, automatable: false,
automatedCostFixed: 0, automatedCostFixed: 0,
automatedTimeMinutes: 0, automatedTimeMinutes: 0,
automatedAssignedResources: [],
} }
} }

View File

@@ -16,7 +16,7 @@ import type { Activity, Process } from '@/domain/types'
// ─── Fixtures de registros "viejos" (sin campos Sprint 1) ───────────────────── // ─── Fixtures de registros "viejos" (sin campos Sprint 1) ─────────────────────
function makeLegacyActivity(id: string, processId: string): Omit<Activity, 'automatable' | 'automatedCostFixed' | 'automatedTimeMinutes'> { function makeLegacyActivity(id: string, processId: string): Omit<Activity, 'automatable' | 'automatedCostFixed' | 'automatedTimeMinutes' | 'automatedAssignedResources'> {
return { return {
id, processId, id, processId,
bpmnElementId: `task_${id}`, bpmnElementId: `task_${id}`,
@@ -28,7 +28,7 @@ function makeLegacyActivity(id: string, processId: string): Omit<Activity, 'auto
} }
} }
function makeLegacyProcess(id: string): Omit<Process, 'annualFrequency' | 'analysisHorizonYears' | 'automationInvestment'> { function makeLegacyProcess(id: string): Omit<Process, 'annualFrequency' | 'analysisHorizonYears' | 'automationInvestment' | 'groupId' | 'tags'> {
return { return {
id, name: 'Proceso Legacy', clientName: 'Cliente Viejo', id, name: 'Proceso Legacy', clientName: 'Cliente Viejo',
bpmnXml: '<definitions/>', currency: 'USD', overheadPercentage: 0.15, bpmnXml: '<definitions/>', currency: 'USD', overheadPercentage: 0.15,
@@ -59,6 +59,7 @@ describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () =>
automatable: false, automatable: false,
automatedCostFixed: 0, automatedCostFixed: 0,
automatedTimeMinutes: 0, automatedTimeMinutes: 0,
automatedAssignedResources: [],
} }
await activityRepo.save(act) await activityRepo.save(act)
const retrieved = await activityRepo.getByProcess('p1') const retrieved = await activityRepo.getByProcess('p1')
@@ -73,6 +74,7 @@ describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () =>
automatable: true, automatable: true,
automatedCostFixed: 12.5, automatedCostFixed: 12.5,
automatedTimeMinutes: 3, automatedTimeMinutes: 3,
automatedAssignedResources: [],
} }
await activityRepo.save(act) await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1') const [retrieved] = await activityRepo.getByProcess('p1')
@@ -87,6 +89,7 @@ describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () =>
automatable: true, automatable: true,
automatedCostFixed: 5, automatedCostFixed: 5,
automatedTimeMinutes: 10, automatedTimeMinutes: 10,
automatedAssignedResources: [],
} }
await activityRepo.save(act) await activityRepo.save(act)
// Actualizar solo directCostFixed — los campos de automatización no deben cambiar // 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, annualFrequency: 1000,
analysisHorizonYears: 3, analysisHorizonYears: 3,
automationInvestment: 0, automationInvestment: 0,
groupId: null, tags: [],
} }
await processRepo.save(proc) await processRepo.save(proc)
const retrieved = await processRepo.getById('proc-1') const retrieved = await processRepo.getById('proc-1')
@@ -119,6 +123,7 @@ describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => {
annualFrequency: 5000, annualFrequency: 5000,
analysisHorizonYears: 5, analysisHorizonYears: 5,
automationInvestment: 75_000, automationInvestment: 75_000,
groupId: null, tags: [],
} }
await processRepo.save(proc) await processRepo.save(proc)
const retrieved = await processRepo.getById('proc-2') const retrieved = await processRepo.getById('proc-2')
@@ -133,6 +138,7 @@ describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => {
annualFrequency: 2000, annualFrequency: 2000,
analysisHorizonYears: 4, analysisHorizonYears: 4,
automationInvestment: 30_000, automationInvestment: 30_000,
groupId: null, tags: [],
} }
await processRepo.save(proc) await processRepo.save(proc)
// Actualizar solo el nombre — los campos de volumetría no deben cambiar // Actualizar solo el nombre — los campos de volumetría no deben cambiar

View File

@@ -85,7 +85,7 @@ describe('motor / propagación con GatewayConfig correcta', () => {
actIds.map((id) => [id, { actIds.map((id) => [id, {
id: uuidv4(), processId, bpmnElementId: id, name: id, id: uuidv4(), processId, bpmnElementId: id, name: id,
type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [], type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
}]) }])
) )

View File

@@ -32,6 +32,8 @@ function makeProcess(id = 'proc-1'): Process {
automationInvestment: 0, automationInvestment: 0,
createdAt: 1000, createdAt: 1000,
updatedAt: 2000, updatedAt: 2000,
groupId: null,
tags: [],
} }
} }
@@ -48,6 +50,7 @@ function makeActivity(id: string, processId: string, bpmnElementId: string): Act
automatable: false, automatable: false,
automatedCostFixed: 0, automatedCostFixed: 0,
automatedTimeMinutes: 0, automatedTimeMinutes: 0,
automatedAssignedResources: [],
} }
} }

View File

@@ -56,6 +56,7 @@ function buildInput(
automatable: false, automatable: false,
automatedCostFixed: 0, automatedCostFixed: 0,
automatedTimeMinutes: 0, automatedTimeMinutes: 0,
automatedAssignedResources: [],
}, },
]) ])
) )

View File

@@ -51,6 +51,7 @@ function buildActivities(xml: string): Map<string, Activity> {
automatable: AUTOMATABLE_IDS.has(el.bpmnElementId), automatable: AUTOMATABLE_IDS.has(el.bpmnElementId),
automatedCostFixed: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_COST : 0, automatedCostFixed: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_COST : 0,
automatedTimeMinutes: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_TIME : 0, automatedTimeMinutes: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_TIME : 0,
automatedAssignedResources: [],
}]) }])
) )
} }

View File

@@ -52,6 +52,7 @@ function buildSimInput(
automatable: automatableIds.has(el.bpmnElementId), automatable: automatableIds.has(el.bpmnElementId),
automatedCostFixed: automatableIds.has(el.bpmnElementId) ? automatedCost : 0, automatedCostFixed: automatableIds.has(el.bpmnElementId) ? automatedCost : 0,
automatedTimeMinutes: automatableIds.has(el.bpmnElementId) ? automatedTime : 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', automatable: el.bpmnElementId === 'task_valid',
automatedCostFixed: 0, // cero — trigger de transparencia automatedCostFixed: 0, // cero — trigger de transparencia
automatedTimeMinutes: 0, // cero — trigger de transparencia automatedTimeMinutes: 0, // cero — trigger de transparencia
automatedAssignedResources: [],
}]) }])
) )

View File

@@ -14,7 +14,7 @@ function makeProcess(extras: Partial<Process> = {}): Process {
id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ', id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ',
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, 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', id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task',
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [], directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5, automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5,
automatedAssignedResources: [],
}, },
{ {
id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task', id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task',
directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [], directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
automatedAssignedResources: [],
}, },
] ]

View File

@@ -9,7 +9,7 @@ function makeProcess(currency: string, extras: Partial<Process> = {}): Process {
id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC', id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC',
bpmnXml: '', currency, overheadPercentage: 0.2, bpmnXml: '', currency, overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, ...extras, createdAt: 0, updatedAt: 0, groupId: null, tags: [], ...extras,
} }
} }

View File

@@ -26,7 +26,7 @@ function buildInput(xml: string, directCost: number): SimulationInput {
actEls.map((el) => [el.bpmnElementId, { actEls.map((el) => [el.bpmnElementId, {
id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, name: el.name, id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, name: el.name,
type: el.type, directCostFixed: directCost, executionTimeMinutes: 30, assignedResources: [], 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, bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [],
} }
const simulation: Simulation = { const simulation: Simulation = {

View File

@@ -51,6 +51,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [],
} }
const perActivityActual = [ const perActivityActual = [
@@ -101,11 +102,13 @@ const mockActivities: Activity[] = [
id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task', id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task',
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [], directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5, automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5,
automatedAssignedResources: [],
}, },
{ {
id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task', id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task',
directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [], directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
automatedAssignedResources: [],
}, },
] ]

View File

@@ -43,6 +43,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [],
} }
// Simulación SIN recursos en ninguna actividad // Simulación SIN recursos en ninguna actividad
@@ -72,6 +73,7 @@ const mockActivity: Activity = {
directCostFixed: 200, executionTimeMinutes: 60, directCostFixed: 200, executionTimeMinutes: 60,
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }], assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
automatedAssignedResources: [],
} }
const mockSimWithResources: Simulation = { const mockSimWithResources: Simulation = {

View File

@@ -54,6 +54,7 @@ const mockProcess: Process = {
overheadPercentage: 0.2, overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [],
} }
const mockSimulation: Simulation = { const mockSimulation: Simulation = {

View File

@@ -59,6 +59,7 @@ const mockProcess: Process = {
overheadPercentage: 0.2, overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [],
} }
const mockSimulation: Simulation = { const mockSimulation: Simulation = {

View File

@@ -30,6 +30,7 @@ const mockActivity: Activity = {
directCostFixed: 600, executionTimeMinutes: 60, directCostFixed: 600, executionTimeMinutes: 60,
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }], assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
automatedAssignedResources: [{ resourceId: 'r1', utilizationMinutes: 5, units: 3 }],
} }
const activityWithResources: ActivitySimResult = { const activityWithResources: ActivitySimResult = {
@@ -122,3 +123,43 @@ describe('drawResourcesSection', () => {
expect(autoTableMock).toHaveBeenCalledTimes(1) expect(autoTableMock).toHaveBeenCalledTimes(1)
}) })
}) })
// ─── Escenario automatizado usa automatedAssignedResources ────────────────────
describe('drawResourcesSection — escenario automatizado', () => {
let autoTableMock: ReturnType<typeof vi.fn>
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')
})
})

View File

@@ -56,6 +56,7 @@ const mockProcess: Process = {
overheadPercentage: 0.2, overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [],
} }
const mockSimulation: Simulation = { const mockSimulation: Simulation = {

View File

@@ -52,6 +52,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [],
} }
const mockRoi: RoiResult = { const mockRoi: RoiResult = {
@@ -103,10 +104,10 @@ const mockSimulation: Simulation = {
const mockActivities: Activity[] = [ const mockActivities: Activity[] = [
{ id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'Tarea A', type: 'task', { id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'Tarea A', type: 'task',
automatable: true, directCostFixed: 400, executionTimeMinutes: 30, 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', { id: 'a2', processId: 'p1', bpmnElementId: 't2', name: 'Tarea B', type: 'task',
automatable: false, directCostFixed: 200, executionTimeMinutes: 20, 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', () => { describe('PDF ROI — Etapa 7 páginas 2-5', () => {

View File

@@ -52,6 +52,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
createdAt: 0, updatedAt: 0, createdAt: 0, updatedAt: 0,
groupId: null, tags: [],
} }
const mockSimulation: Simulation = { const mockSimulation: Simulation = {

View File

@@ -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> = {}): Process {
return {
id,
name: `Proceso ${id}`,
clientName: 'Cliente Test',
bpmnXml: '<definitions/>',
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<string, unknown> = {
id: 'p1',
name: 'Proceso Legado',
clientName: 'Cliente A',
bpmnXml: '<definitions/>',
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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)
})
})

View File

@@ -94,6 +94,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
currency: 'USD', overheadPercentage: 0.2, currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000, annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000,
createdAt: Date.now(), updatedAt: Date.now(), createdAt: Date.now(), updatedAt: Date.now(),
groupId: null, tags: [],
} }
useProcessStore.setState({ currentProcess: proc }) useProcessStore.setState({ currentProcess: proc })
@@ -110,7 +111,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
type: 'task', type: 'task',
directCostFixed: 999, // campo modificado directCostFixed: 999, // campo modificado
executionTimeMinutes: 30, assignedResources: [], executionTimeMinutes: 30, assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
}] }]
useProcessStore.setState({ activities: newActivities }) useProcessStore.setState({ activities: newActivities })
@@ -122,7 +123,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
const activities: Activity[] = [{ const activities: Activity[] = [{
id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'T', id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'T',
type: 'task', directCostFixed: 100, executionTimeMinutes: 30, type: 'task', directCostFixed: 100, executionTimeMinutes: 30,
assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
}] }]
useProcessStore.setState({ activities }) useProcessStore.setState({ activities })