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:
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 622 B After Width: | Height: | Size: 46 KiB |
BIN
public/inquality-logo-color.png
Normal file
BIN
public/inquality-logo-color.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
public/inquality-logo-white.png
Normal file
BIN
public/inquality-logo-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
440
sprints/sprint-3/BRIEF.md
Normal file
440
sprints/sprint-3/BRIEF.md
Normal 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) | 60–70 % |
|
||||
| PDF (refinamientos, nuevas secciones) | 15–25 % |
|
||||
| Infraestructura / deuda técnica | 10–15 % |
|
||||
| Documentación / metodología | 5–10 % |
|
||||
|
||||
### 1.2 Criterio de wow (obligatorio)
|
||||
|
||||
Cada etapa con feature nueva incluye:
|
||||
- **Versión mínima:** cumple la funcionalidad.
|
||||
- **Versión con wow:** mismo objetivo + 1–2 detalles de experiencia que generan impacto positivo.
|
||||
|
||||
Por defecto: versión con wow si el costo extra < 40% del tiempo de la mínima.
|
||||
|
||||
### 1.3 Decisión estratégica: PostgreSQL diferido
|
||||
|
||||
La persistencia en Dexie (IndexedDB) se mantiene hasta Sprint 5+. La decisión fue tomada conscientemente: el driver para una migración a PostgreSQL (multi-usuario, multi-tenant) no existe todavía en el uso interno de InQuality. El schema de Dexie v4 debe diseñarse con relaciones normalizadas y UUIDs para que la migración futura a SQL sea limpia.
|
||||
|
||||
---
|
||||
|
||||
## 2. Estado real del producto (pre-Sprint 3)
|
||||
|
||||
### 2.1 Lo que existe en la persistencia (Dexie v3)
|
||||
|
||||
| Tabla | Estado |
|
||||
|---|---|
|
||||
| `processes` | ✅ `{ id, name, clientName, bpmnXml, currency, overheadPercentage, annualFrequency, analysisHorizonYears, automationInvestment, createdAt, updatedAt }` |
|
||||
| `activities` | ✅ incluye `assignedResources: ActivityResourceAssignment[]`, `automatable`, `automatedCostFixed`, `automatedTimeMinutes` |
|
||||
| `resources` | ✅ `{ id, processId, name, type, costPerHour }` |
|
||||
| `gateways` | ✅ `{ id, processId, bpmnElementId, gatewayType, branches }` |
|
||||
| `simulations` | ✅ `{ id, processId, executedAt, result, resultAutomated? }` |
|
||||
|
||||
### 2.2 Lo que falta y se construye en Sprint 3
|
||||
|
||||
| Elemento | Estado actual | Sprint 3 |
|
||||
|---|---|---|
|
||||
| Agrupación de procesos | ❌ No existe | ✅ Tabla `groups` + `groupId` en `Process` |
|
||||
| Tags en procesos | ❌ No existe | ✅ Campo `tags: string[]` en `Process` |
|
||||
| Configuración de etiquetas | ❌ No existe | ✅ Tabla `settings` con `groupLabel` configurable |
|
||||
| Vista de biblioteca | ❌ No existe | ✅ Home navegable con grupos y procesos |
|
||||
| Búsqueda y filtros | ❌ No existe | ✅ Búsqueda por nombre + filtro por tags |
|
||||
| Indicador stale | ❌ No existe | ✅ Badge cuando proceso tiene cambios sin simular |
|
||||
| Zoom/fit canvas BPMN | ❌ No existe | ✅ Botón fit-to-viewport |
|
||||
| Recursos en escenario automatizado | ❌ `automatedCostFixed` solamente | ✅ `automatedAssignedResources[]` + simulación |
|
||||
|
||||
---
|
||||
|
||||
## 3. Decisiones arquitectónicas del sprint (validadas por el director)
|
||||
|
||||
| Decisión | Resolución |
|
||||
|---|---|
|
||||
| Versión Dexie | Incrementar a v4. Migración preserva datos existentes. |
|
||||
| Jerarquía de biblioteca | Dos niveles: Grupo (etiqueta configurable) + Proceso |
|
||||
| Etiquetas configurables | Una sola etiqueta para el nivel de grupo (`groupLabel`). Default: `"Cliente"`. Configurable por workspace. |
|
||||
| Scope de `settings` | Singleton en IndexedDB — un registro por instalación. Preparado para extensión en Sprint 5+ cuando llegue multi-tenant. |
|
||||
| Indicador stale | Comparar `process.updatedAt` vs `latestSimulation.executedAt`. Requiere garantizar que guardar activity/resource/gateway actualiza `process.updatedAt`. Sin campo nuevo. |
|
||||
| Recursos en automatizado | Nuevo campo `automatedAssignedResources: ActivityResourceAssignment[]` en `Activity`. Migración v4 popula con array vacío. |
|
||||
| Costo automatizado total | `automatedCostTotal = automatedCostFixed + SUM(automatedResourceCosts)` — mismo patrón que AS-IS |
|
||||
| UUIDs en nuevas tablas | `groups` y `settings` usan UUIDs para compatibilidad futura con PostgreSQL |
|
||||
| PostgreSQL | Diferido a Sprint 5+. No afecta diseño de Sprint 3 salvo por las consideraciones de UUIDs y normalización. |
|
||||
|
||||
### 3.1 Schema Dexie v4 — cambios
|
||||
|
||||
```typescript
|
||||
// NUEVA tabla — ProcessGroup
|
||||
export interface ProcessGroup {
|
||||
id: UUID // generado con crypto.randomUUID()
|
||||
name: string
|
||||
createdAt: number // timestamp Unix ms
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
// NUEVA tabla — AppSettings (singleton)
|
||||
export interface AppSettings {
|
||||
id: 'singleton' // siempre un solo registro
|
||||
groupLabel: string // default: 'Cliente'
|
||||
}
|
||||
|
||||
// Process — campos NUEVOS (migración v4 popula con defaults)
|
||||
export interface Process {
|
||||
// ... campos existentes sin cambio ...
|
||||
groupId: UUID | null // 🆕 null = "Sin clasificar"
|
||||
tags: string[] // 🆕 []
|
||||
}
|
||||
|
||||
// Activity — campo NUEVO (migración v4 popula con [])
|
||||
export interface Activity {
|
||||
// ... campos existentes sin cambio ...
|
||||
automatedAssignedResources: ActivityResourceAssignment[] // 🆕 []
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Migración v3 → v4
|
||||
|
||||
```typescript
|
||||
this.version(4)
|
||||
.stores({
|
||||
processes: 'id, name, updatedAt, groupId', // groupId indexado para queries por grupo
|
||||
activities: 'id, processId, bpmnElementId',
|
||||
gateways: 'id, processId, bpmnElementId',
|
||||
resources: 'id, processId, name',
|
||||
simulations:'id, processId, executedAt',
|
||||
groups: 'id, name, updatedAt', // nueva tabla
|
||||
settings: 'id', // nueva tabla (singleton)
|
||||
})
|
||||
.upgrade(async (tx) => {
|
||||
// Processes: agregar groupId y tags
|
||||
await tx.table('processes').toCollection().modify((proc) => {
|
||||
if (proc.groupId === undefined) proc.groupId = null
|
||||
if (!Array.isArray(proc.tags)) proc.tags = []
|
||||
})
|
||||
// Activities: agregar automatedAssignedResources
|
||||
await tx.table('activities').toCollection().modify((act) => {
|
||||
if (!Array.isArray(act.automatedAssignedResources)) {
|
||||
act.automatedAssignedResources = []
|
||||
}
|
||||
})
|
||||
// Settings: crear singleton con defaults
|
||||
await tx.table('settings').put({ id: 'singleton', groupLabel: 'Cliente' })
|
||||
})
|
||||
```
|
||||
|
||||
### 3.3 Fórmula de costo automatizado actualizada
|
||||
|
||||
```
|
||||
costoRecursoAutomatizado = resource.costPerHour × assignment.utilizationMinutes / 60 × assignment.units
|
||||
costoAutomatizadoActividad = automatedCostFixed + SUM(costoRecursoAutomatizado por cada automatedAssignment)
|
||||
```
|
||||
|
||||
La función `simulate(input, scenario)` ya existe. Al pasar `scenario = 'automated'`, para actividades `automatable = true`, usar `automatedAssignedResources` en lugar de `assignedResources`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Features del sprint
|
||||
|
||||
---
|
||||
|
||||
### Feature A — Dexie v4: fundación del modelo de biblioteca (Etapa 1)
|
||||
|
||||
**Peso estimado:** 10% del sprint.
|
||||
|
||||
Data layer puro. Sin cambios de UI. Objetivo: que el schema esté correcto y los datos existentes preservados antes de construir cualquier UI encima.
|
||||
|
||||
- Nuevas interfaces `ProcessGroup` y `AppSettings` en `types.ts`
|
||||
- Nuevos campos `groupId` y `tags` en `Process`, `automatedAssignedResources` en `Activity`
|
||||
- `db.ts`: versión 4 con las dos nuevas tablas y función de upgrade
|
||||
- Tests de migración: proceso existente conserva todos los campos, `groupId = null`, `tags = []`, `automatedAssignedResources = []`
|
||||
- Test de idempotencia: ejecutar upgrade dos veces no corrompe datos
|
||||
|
||||
**Criterio de aceptación:**
|
||||
- [ ] Migración v3→v4 sin pérdida de datos (test automatizado)
|
||||
- [ ] `ProcessGroup` y `AppSettings` disponibles en TypeScript sin errores
|
||||
- [ ] `settings` singleton creado con `groupLabel: 'Cliente'` por defecto
|
||||
- [ ] Build limpio sin errores TypeScript
|
||||
- [ ] `git diff --name-only HEAD` muestra solo los archivos autorizados
|
||||
|
||||
**Archivos autorizados:** `src/domain/types.ts`, `src/persistence/db.ts`, tests de migración.
|
||||
|
||||
---
|
||||
|
||||
### Feature B — Vista de biblioteca + gestión de grupos (Etapa 2)
|
||||
|
||||
**Peso estimado:** 20% del sprint.
|
||||
|
||||
La biblioteca pasa a ser la home de la aplicación. El flujo anterior (importar BPMN directamente al llegar) se mantiene como acción secundaria desde la biblioteca.
|
||||
|
||||
**Versión mínima:**
|
||||
- Nueva ruta `/` o equivalente que muestra la biblioteca
|
||||
- Lista de grupos con contador de procesos
|
||||
- Sección "Sin clasificar" para `groupId = null`
|
||||
- CRUD de grupos: crear, renombrar, eliminar
|
||||
- Eliminar grupo: procesos del grupo pasan a "Sin clasificar" (no se borran)
|
||||
- Vista de procesos dentro de un grupo: nombre, cliente, fecha de última modificación
|
||||
- Botón "Nuevo proceso" (importar BPMN) disponible globalmente y dentro de cada grupo
|
||||
- Al crear/importar un proceso desde dentro de un grupo, asigna `groupId` automáticamente
|
||||
|
||||
**Versión con wow (recomendada):**
|
||||
Todo lo anterior más:
|
||||
- Empty state diseñado para la primera vez (sin grupos ni procesos): ilustración o mensaje de bienvenida con CTA para crear el primer grupo o importar un proceso
|
||||
- Empty state para grupo vacío: "Este grupo no tiene procesos. Importá un BPMN para empezar."
|
||||
- Hover sobre proceso: preview con fecha de última simulación y costo total (si existe)
|
||||
- Breadcrumb al entrar a un grupo: `groupLabel / nombre del grupo`
|
||||
|
||||
**Criterio de aceptación:**
|
||||
- [ ] Navegación entre biblioteca y grupos funciona sin recargar
|
||||
- [ ] CRUD de grupos opera correctamente (incluyendo caso de borrado con procesos)
|
||||
- [ ] Procesos existentes sin grupo aparecen en "Sin clasificar"
|
||||
- [ ] Asignación automática de grupo al crear proceso desde dentro de uno
|
||||
- [ ] Validación visual del director aprobada
|
||||
|
||||
---
|
||||
|
||||
### Feature C — Tags + búsqueda + configuración de etiquetas (Etapa 3)
|
||||
|
||||
**Peso estimado:** 15% del sprint.
|
||||
|
||||
**C.1 — Tags en procesos**
|
||||
|
||||
Chip input en el workspace del proceso (cerca del nombre y cliente) para agregar y quitar tags. Los tags son strings libres. Autocompletado con tags ya existentes en la base (evita fragmentación "BPMN" / "bpmn" / "Bpmn").
|
||||
|
||||
**C.2 — Búsqueda y filtros en biblioteca**
|
||||
|
||||
- Búsqueda en tiempo real por nombre de proceso (filtra mientras se escribe)
|
||||
- Filtro por tags: chips seleccionables que muestran solo procesos con esos tags
|
||||
- Los filtros activos se muestran visualmente como chips removibles
|
||||
- La búsqueda opera tanto dentro de un grupo como en "todos los procesos"
|
||||
|
||||
**C.3 — Configuración de etiquetas**
|
||||
|
||||
Pantalla de configuración (ruta `/settings` o modal) donde el usuario define:
|
||||
- `groupLabel`: cómo se llama el nivel de agrupación (default: "Cliente"). Este label aparece en toda la UI donde antes decía "Grupo".
|
||||
- La configuración persiste en la tabla `settings` de Dexie.
|
||||
|
||||
**Versión con wow (recomendada):**
|
||||
Todo lo anterior más:
|
||||
- En la vista de biblioteca sin filtro activo: mostrar un tag cloud visual con los tags más usados — click en un tag activa el filtro
|
||||
- El label configurado se refleja en tiempo real en la UI sin recargar (reactive desde el settings store)
|
||||
|
||||
**Criterio de aceptación:**
|
||||
- [ ] Tags se guardan y persisten en el proceso
|
||||
- [ ] Autocompletado funciona con tags existentes (case-insensitive)
|
||||
- [ ] Búsqueda filtra procesos en tiempo real
|
||||
- [ ] Filtro por tags funciona en modo AND (proceso debe tener todos los tags seleccionados)
|
||||
- [ ] `groupLabel` configurable y reflejado en la UI
|
||||
- [ ] Validación visual del director aprobada
|
||||
|
||||
---
|
||||
|
||||
### Feature D — Indicador de simulación desactualizada + zoom BPMN (Etapa 4)
|
||||
|
||||
**Peso estimado:** 10% del sprint.
|
||||
|
||||
**D.1 — Indicador stale**
|
||||
|
||||
Cuando el usuario guarda cambios en actividades, recursos o gateways de un proceso sin volver a simular, el workspace muestra un indicador persistente.
|
||||
|
||||
Implementación: garantizar que `db.processes.update(id, { updatedAt: Date.now() })` se ejecuta siempre que se guarden activities, resources o gateways del proceso. El indicador es `process.updatedAt > (latestSimulation?.executedAt ?? 0)`.
|
||||
|
||||
**Versión mínima:** badge "Simular para actualizar" en el botón Simular (o cerca). Desaparece al ejecutar.
|
||||
|
||||
**Versión con wow (recomendada):**
|
||||
Todo lo anterior más:
|
||||
- El indicador menciona cuántas actividades tienen cambios sin simular (requiere comparar `activity.updatedAt` — agregar este campo solo si el esfuerzo es < 40% del mínimo; si no, omitir)
|
||||
- El indicador también aparece en el reporte web como banner sutil: "Los resultados pueden estar desactualizados — hay cambios sin simular" con botón "Simular ahora"
|
||||
|
||||
**D.2 — Zoom/fit canvas BPMN**
|
||||
|
||||
Botón "Encuadrar diagrama" (ícono fit-to-screen o similar) en la toolbar del BpmnCanvas. Al hacer click: `bpmnViewer.get('canvas').zoom('fit-viewport')`. Detectado en validación Sprint 2 como problema real en talleres.
|
||||
|
||||
**Criterio de aceptación:**
|
||||
- [ ] Indicador aparece al guardar actividad sin re-simular
|
||||
- [ ] Indicador desaparece al ejecutar simulación
|
||||
- [ ] Botón fit-to-viewport funciona en BpmnCanvas
|
||||
- [ ] Validación visual del director aprobada
|
||||
|
||||
---
|
||||
|
||||
### Feature E — Recursos en escenario automatizado (Etapa 5)
|
||||
|
||||
**Peso estimado:** 20% del sprint.
|
||||
|
||||
Esta es la feature más compleja de Modelo A. El campo `automatedAssignedResources` ya existe en el schema (creado en Etapa 1). Etapa 5 construye la UI y la lógica de simulación.
|
||||
|
||||
**E.1 — UI en ActivityPanel**
|
||||
|
||||
En la sección "Automatización" del ActivityPanel (visible cuando `automatable = true`), agregar una subsección "Recursos automatizados" paralela a la del escenario actual:
|
||||
- Mismo componente de asignación de recursos (resourceId, utilizationMinutes, units)
|
||||
- Recursos disponibles: los mismos del catálogo del proceso (tabla `resources`)
|
||||
- Label diferenciador: "Recursos en AS-IS" / "Recursos en TO-BE" (o "Actual" / "Automatizado")
|
||||
|
||||
**E.2 — Motor de simulación**
|
||||
|
||||
Al simular con `scenario = 'automated'`, para actividades `automatable = true`:
|
||||
- Usar `automatedAssignedResources` en lugar de `assignedResources`
|
||||
- Costo total = `automatedCostFixed + SUM(costoRecursoAutomatizado)`
|
||||
- El `resourceCostBreakdown` en `ActivitySimResult` refleja los recursos automatizados
|
||||
|
||||
**E.3 — Reporte web**
|
||||
|
||||
En las filas expandibles de `ActivitiesTable` para actividades automatizables:
|
||||
- **Versión mínima:** mostrar desglose de recursos del escenario que corresponde (actual en columna actual, automatizado en columna automatizado)
|
||||
- **Versión con wow:** comparación lado a lado — fila expandida muestra "Actual: X recursos" / "Automatizado: Y recursos" con sus costos individuales
|
||||
|
||||
**Criterio de aceptación:**
|
||||
- [ ] Se pueden asignar recursos al escenario automatizado desde el ActivityPanel
|
||||
- [ ] La simulación usa esos recursos al calcular el costo automatizado
|
||||
- [ ] El reporte muestra el desglose de recursos del escenario automatizado
|
||||
- [ ] Escenario sin recursos automatizados: comportamiento idéntico al anterior (solo `automatedCostFixed`)
|
||||
- [ ] Tests unitarios: costo automatizado con recursos, sin recursos, mixto (fijo + recursos)
|
||||
- [ ] Validación visual del director aprobada
|
||||
|
||||
---
|
||||
|
||||
### Feature F — PDF actualizado + tests del sprint (Etapa 6)
|
||||
|
||||
**Peso estimado:** 15% del sprint.
|
||||
|
||||
**F.1 — PDF con recursos automatizados**
|
||||
|
||||
Si el proceso tiene actividades con `automatedAssignedResources`, la tabla de recursos en página 4 del PDF (introducida en Sprint 2) muestra ambos escenarios:
|
||||
- Sección "Recursos AS-IS" (existente)
|
||||
- Sección "Recursos TO-BE" (nueva, condicional — solo si hay recursos automatizados)
|
||||
|
||||
Si ninguna actividad tiene recursos automatizados, la tabla PDF no cambia respecto a Sprint 2.
|
||||
|
||||
**F.2 — Tests del sprint**
|
||||
|
||||
- Tests unitarios de migración Dexie v4 (cobertura de los nuevos campos)
|
||||
- Tests unitarios del motor de simulación con `automatedAssignedResources`
|
||||
- Test E2E del flujo completo de la biblioteca: crear grupo → importar proceso → asignar a grupo → agregar tags → buscar → filtrar
|
||||
- Tests de la configuración de `groupLabel` (cambio se refleja en UI)
|
||||
|
||||
**Criterio de aceptación:**
|
||||
- [ ] PDF con sección TO-BE aparece cuando hay recursos automatizados
|
||||
- [ ] PDF sin recursos automatizados: sin cambio respecto a Sprint 2
|
||||
- [ ] Todos los tests nuevos del sprint verdes
|
||||
- [ ] Validación visual del director aprobada (abrir el PDF generado)
|
||||
|
||||
---
|
||||
|
||||
### Feature G — Polish + validación final (Etapa 7)
|
||||
|
||||
**Peso estimado:** 10% del sprint.
|
||||
|
||||
Refinamientos post-validación visual del director de las etapas anteriores. Edge cases de la biblioteca (nombre de grupo muy largo, proceso sin nombre, muchos tags, tag muy largo). Ajustes de animación o spacing que emerjan en uso real. Cualquier inconsistencia de estilo entre la biblioteca nueva y el resto de la app.
|
||||
|
||||
Esta etapa no tiene scope predefinido — se construye a partir del feedback acumulado de la validación visual de Etapas 2-6.
|
||||
|
||||
**Criterio de aceptación:**
|
||||
- [ ] Director da OK formal al producto visual completo del sprint
|
||||
- [ ] Sin regresiones en features existentes (tests E2E del sprint anterior siguen verdes)
|
||||
|
||||
---
|
||||
|
||||
## 5. Estructura de etapas
|
||||
|
||||
| Etapa | Contenido | Peso |
|
||||
|---|---|---|
|
||||
| 1 | Dexie v4: nuevas tablas, campos, migración, tests | 10% |
|
||||
| 2 | Vista de biblioteca + gestión de grupos | 20% |
|
||||
| 3 | Tags + búsqueda + configuración de etiquetas | 15% |
|
||||
| 4 | Indicador simulación desactualizada + zoom BPMN | 10% |
|
||||
| 5 | Recursos en escenario automatizado (UI + simulación + reporte) | 20% |
|
||||
| 6 | PDF recursos automatizados + tests del sprint | 15% |
|
||||
| 7 | Polish + validación final | 10% |
|
||||
|
||||
**Proporción por componente:** web app ~75% (etapas 2, 3, 4, 5) · PDF + tests ~25% (etapas 1, 6, 7).
|
||||
|
||||
**Regla operativa:** ninguna etapa arranca sin su `ETAPA_X_PROMPT.md` aprobado por el director.
|
||||
|
||||
---
|
||||
|
||||
## 6. Lo que explícitamente NO entra en Sprint 3
|
||||
|
||||
- Catálogo global de recursos compartido entre procesos → evaluar Sprint 4 con feedback de uso real
|
||||
- Modelo B (AS-IS / TO-BE con dos BPMNs) → decisión diferida a Sprint 4+
|
||||
- Múltiples simulaciones / historial de escenarios → BACKLOG
|
||||
- Migración a PostgreSQL → Sprint 5+ (decisión tomada explícitamente)
|
||||
- Login, multi-tenant, colaboración → Fase 2
|
||||
- Gateway OR inclusivo nativo → BACKLOG
|
||||
- Distribuciones de probabilidad → Fase 2
|
||||
- Export Excel → BACKLOG
|
||||
|
||||
---
|
||||
|
||||
## 7. Deuda técnica que se cierra en este sprint
|
||||
|
||||
| Ítem en TECH_DEBT.md | Etapa |
|
||||
|---|---|
|
||||
| Toggle automatable + Guardar requerido para badge (INVESTIGATING) | 4 — resolver como parte del indicador stale |
|
||||
| Indicador visual actividades automatizables en reporte (BACKLOG) | 5 — cubierto por desglose en filas expandibles |
|
||||
| Controles de zoom en canvas BPMN (BACKLOG) | 4 |
|
||||
|
||||
---
|
||||
|
||||
## 8. Definition of Done — Sprint 3
|
||||
|
||||
- [ ] Features A–G: todos los criterios de aceptación cumplidos
|
||||
- [ ] Migración Dexie v3→v4: test automatizado confirma preservación de datos y correctos defaults
|
||||
- [ ] Tests unitarios: motor de simulación con `automatedAssignedResources` (sin recursos, solo fijo, mixto, solo recursos)
|
||||
- [ ] Test E2E: flujo completo de biblioteca (crear grupo, asignar proceso, tags, búsqueda, filtro)
|
||||
- [ ] Validación visual: biblioteca navegable, indicador stale, comparación recursos en reporte, PDF TO-BE
|
||||
- [ ] TECH_DEBT.md actualizado: ítems cerrados documentados
|
||||
- [ ] OBSERVATIONS.md limpio al cerrar
|
||||
- [ ] STRATEGIC_DIRECTION.md actualizado con reflexiones post-sprint
|
||||
- [ ] Ratio web/PDF respetado (~75% web, ~25% PDF + infra)
|
||||
|
||||
---
|
||||
|
||||
## 9. Riesgos
|
||||
|
||||
| Riesgo | Probabilidad | Mitigación |
|
||||
|---|---|---|
|
||||
| La Etapa 5 (recursos automatizados) tiene dependencias de tipo transitivas con archivos de etapas anteriores | Media | Incluir instrucción AP.9 explícita: si hay conflicto en archivos restringidos, DETENER y reportar. |
|
||||
| El indicador stale requiere rastrear `updatedAt` a nivel de actividad (actualmente no existe) | Media | Especificado en el BRIEF: solo comparar `process.updatedAt` vs `simulation.executedAt`. Si se quiere granularidad por actividad, evaluar esfuerzo antes de comprometerse. |
|
||||
| La biblioteca reemplaza la home y puede romper el flujo de importación existente | Media | Diseñar la biblioteca para que el flujo de importación sea acción prominente, no secundaria. Testear el E2E de importación como parte de Etapa 2. |
|
||||
| `settings` singleton asume una instalación = un usuario. Si en el futuro hay multi-usuario en el mismo browser, colisiona. | Baja | Aceptado conscientemente. El diseño con `id: 'singleton'` es limpio y fácil de migrar cuando llegue multi-tenant. |
|
||||
|
||||
---
|
||||
|
||||
## 10. Referencias
|
||||
|
||||
- `CLAUDE.md` (simulador-web) — reglas de implementación
|
||||
- `STRATEGIC_DIRECTION.md` — decisiones estratégicas post-Sprint 2
|
||||
- `BACKLOG.md` — ítems "Indicador simulación desactualizada", "Zoom BPMN", "Tags"
|
||||
- `TECH_DEBT.md` — ítems toggle automatable, indicador visual, zoom
|
||||
- `methodology/PATTERNS.md` — C.6, C.7
|
||||
- `methodology/ANTI_PATTERNS.md` — AP.8, AP.9
|
||||
- `sprints/sprint-2/BRIEF.md` — contexto del modelo de recursos y Dexie v3
|
||||
- `methodology/CASE_STUDIES/inq-roi-sprint-2.md` — lecciones del sprint anterior
|
||||
- `assets/InQ manual de marca- 3.0.pdf` — manual de marca oficial
|
||||
215
sprints/sprint-3/ETAPA_1_PROMPT.md
Normal file
215
sprints/sprint-3/ETAPA_1_PROMPT.md
Normal 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)
|
||||
338
sprints/sprint-3/ETAPA_2_PROMPT.md
Normal file
338
sprints/sprint-3/ETAPA_2_PROMPT.md
Normal 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)
|
||||
242
sprints/sprint-3/ETAPA_3_PROMPT.md
Normal file
242
sprints/sprint-3/ETAPA_3_PROMPT.md
Normal 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)
|
||||
307
sprints/sprint-3/ETAPA_4_PROMPT.md
Normal file
307
sprints/sprint-3/ETAPA_4_PROMPT.md
Normal 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)
|
||||
445
sprints/sprint-3/ETAPA_5_PROMPT.md
Normal file
445
sprints/sprint-3/ETAPA_5_PROMPT.md
Normal 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 1–4 completadas. Dexie v4 activo. Biblioteca operativa con grupos, tags, búsqueda. Indicador stale funcionando (persiste en recarga). Botón fit-viewport en BpmnCanvas.
|
||||
- `Activity.automatedAssignedResources?: ActivityResourceAssignment[]` existe en `types.ts` como campo opcional desde Etapa 1. La migración v4 ya popula las actividades existentes con `[]`.
|
||||
- `simulation.ts` actualmente usa `activity.assignedResources` en ambos escenarios. La rama `useAutomated` solo cambia `directCostFixed` y `executionTimeMinutes`, pero **no cambia los recursos** — bug intencional pendiente de esta etapa.
|
||||
- `ActivityPanel.tsx` tiene la sección "Automatización" con toggle, `automatedCostFixed` e `automatedTimeMinutes`. Los recursos automatizados no tienen UI todavía.
|
||||
- `ActivitiesTable.tsx` recibe `perActivity: ActivitySimResult[]` y muestra `resourceCostBreakdown` en filas expandibles.
|
||||
|
||||
**Esta etapa conecta las tres capas: motor de simulación, UI de edición, y visualización en reporte.**
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
**E.1 — Motor de simulación**
|
||||
Cuando `useAutomated = true`, usar `activity.automatedAssignedResources ?? []` en lugar de `activity.assignedResources` para el cálculo de costos de recursos.
|
||||
|
||||
**E.2 — UI en ActivityPanel**
|
||||
Agregar subsección "Recursos TO-BE" dentro de la sección Automatización del ActivityPanel (visible cuando `automatable = true`). Mismo patrón de asignación que los recursos AS-IS.
|
||||
|
||||
**E.3 — Reporte web (versión mínima)**
|
||||
Sin cambios necesarios: cuando el motor usa `automatedAssignedResources`, el `resourceCostBreakdown` de `SimulationResult` ya refleja correctamente los recursos del escenario automatizado. Las filas expandibles existentes en `ActivitiesTable` muestran el breakdown del escenario que corresponde a cada tab.
|
||||
|
||||
**E.3 wow — Comparación lado a lado**
|
||||
En el tab "Automatizado", las filas expandibles de actividades automatizables muestran AMBOS breakdowns: "Recursos AS-IS" y "Recursos TO-BE", para facilitar la comparación.
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura — leer antes de implementar
|
||||
|
||||
### `automatedAssignedResources` sigue siendo opcional en types.ts
|
||||
|
||||
**No cambiar `types.ts` en esta etapa.** El campo permanece como `automatedAssignedResources?: ActivityResourceAssignment[]`. La promoción a requerido + actualización de tests va en Etapa 6 para hacer el cambio en un solo paso limpio.
|
||||
|
||||
Consecuencia: en todo el código nuevo usar `activity.automatedAssignedResources ?? []` para manejar el caso de actividades antiguas que podrían no tener el campo.
|
||||
|
||||
### Dependencias autorizadas
|
||||
|
||||
`ActivityPanel` ya importa de `useProcessStore` para leer `resources`. No necesita importar nuevas stores para esta feature.
|
||||
|
||||
---
|
||||
|
||||
## Cambios autorizados y arquitectura
|
||||
|
||||
### `src/domain/simulation.ts`
|
||||
|
||||
**Cambio mínimo: usar los recursos correctos según el escenario.**
|
||||
|
||||
Localizar el bloque de la línea ~207:
|
||||
```typescript
|
||||
// ACTUAL (usa siempre assignedResources):
|
||||
for (const assignment of activity.assignedResources) {
|
||||
```
|
||||
|
||||
Reemplazar por:
|
||||
```typescript
|
||||
// CORRECTO: seleccionar el array según el escenario
|
||||
const resourceAssignments = useAutomated
|
||||
? (activity.automatedAssignedResources ?? [])
|
||||
: activity.assignedResources
|
||||
|
||||
for (const assignment of resourceAssignments) {
|
||||
```
|
||||
|
||||
Este es el único cambio en `simulation.ts`. No tocar nada más en este archivo.
|
||||
|
||||
### `src/features/import/ImportPage.tsx`
|
||||
|
||||
Agregar `automatedAssignedResources: []` a la construcción de actividades (~línea 89):
|
||||
|
||||
```typescript
|
||||
const activities: Activity[] = activityElements.map((el) => ({
|
||||
id: uuidv4(),
|
||||
processId,
|
||||
bpmnElementId: el.bpmnElementId,
|
||||
name: el.name,
|
||||
type: el.type,
|
||||
directCostFixed: 0,
|
||||
executionTimeMinutes: 0,
|
||||
assignedResources: [],
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [], // 🆕
|
||||
}))
|
||||
```
|
||||
|
||||
**Clasificación de iniciativa:** Nivel 1 — compatibilidad preventiva, no hay cambio visible.
|
||||
|
||||
### `src/features/workspace/ActivityPanel.tsx`
|
||||
|
||||
#### 1 — Inicialización del estado local
|
||||
|
||||
Actualizar el `setLocalActivity` inicial para incluir `automatedAssignedResources`:
|
||||
|
||||
```typescript
|
||||
setLocalActivity(activity ? {
|
||||
...activity,
|
||||
assignedResources: [...activity.assignedResources],
|
||||
automatedAssignedResources: [...(activity.automatedAssignedResources ?? [])],
|
||||
} : null)
|
||||
```
|
||||
|
||||
#### 2 — Handlers de recursos automatizados
|
||||
|
||||
Agregar tres handlers paralelos a los de AS-IS (cerca de `addResource`, `removeResource`, `updateResourceAssignment`):
|
||||
|
||||
```typescript
|
||||
const addAutomatedResource = (resourceId: string) => {
|
||||
if (!localActivity) return
|
||||
const already = (localActivity.automatedAssignedResources ?? []).some(
|
||||
(r) => r.resourceId === resourceId
|
||||
)
|
||||
if (already) return
|
||||
handleChange('automatedAssignedResources', [
|
||||
...(localActivity.automatedAssignedResources ?? []),
|
||||
{ resourceId, utilizationMinutes: 60, units: 1 },
|
||||
])
|
||||
}
|
||||
|
||||
const removeAutomatedResource = (resourceId: string) => {
|
||||
if (!localActivity) return
|
||||
handleChange(
|
||||
'automatedAssignedResources',
|
||||
(localActivity.automatedAssignedResources ?? []).filter((r) => r.resourceId !== resourceId)
|
||||
)
|
||||
}
|
||||
|
||||
const updateAutomatedAssignment = (
|
||||
resourceId: string,
|
||||
field: 'utilizationMinutes' | 'units',
|
||||
value: number
|
||||
) => {
|
||||
if (!localActivity) return
|
||||
handleChange(
|
||||
'automatedAssignedResources',
|
||||
(localActivity.automatedAssignedResources ?? []).map((r) =>
|
||||
r.resourceId === resourceId ? { ...r, [field]: value } : r
|
||||
)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### 3 — Dropdown de recursos disponibles para TO-BE
|
||||
|
||||
```typescript
|
||||
const availableAutomatedResources = resources.filter(
|
||||
(r) => !(localActivity.automatedAssignedResources ?? []).some((ar) => ar.resourceId === r.id)
|
||||
)
|
||||
```
|
||||
|
||||
#### 4 — Costo total de recursos TO-BE (para mostrar en UI)
|
||||
|
||||
```typescript
|
||||
const localAutomatedResourceCost = (localActivity.automatedAssignedResources ?? []).reduce(
|
||||
(total, assignment) => {
|
||||
const r = resources.find((res) => res.id === assignment.resourceId)
|
||||
if (!r) return total
|
||||
return total + r.costPerHour * (assignment.utilizationMinutes / 60) * assignment.units
|
||||
},
|
||||
0
|
||||
)
|
||||
```
|
||||
|
||||
#### 5 — UI dentro del contenedor de Automatización
|
||||
|
||||
**Cambio requerido previo:** el contenedor animado actual tiene `max-h-80` (320px), insuficiente para el nuevo contenido. Cambiar a `max-h-[640px]`.
|
||||
|
||||
Dentro del contenedor animado (después de los inputs `automatedCostFixed` y `automatedTimeMinutes`), agregar:
|
||||
|
||||
```tsx
|
||||
{/* Separator + Recursos TO-BE */}
|
||||
<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)
|
||||
270
sprints/sprint-3/ETAPA_6_PROMPT.md
Normal file
270
sprints/sprint-3/ETAPA_6_PROMPT.md
Normal 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 1–5 completadas. Motor de simulación usa `automatedAssignedResources` correctamente. ActivityPanel tiene sección "Recursos TO-BE". Reporte web muestra comparación AS-IS/TO-BE en filas expandibles.
|
||||
- `Activity.automatedAssignedResources` sigue siendo `?` (opcional) en `types.ts` — pendiente de esta etapa.
|
||||
- `drawResourcesSection` en `pdf-sections.ts` lee `activityRow?.assignedResources` para los campos `utilizationMinutes` y `units`, incluso cuando se exporta el tab "automatizado". Esto es un bug: para el escenario automatizado debería leer `automatedAssignedResources`.
|
||||
- Los factories de Activity en tests (`makeAutoActivity`, `makeActivity`) no incluyen `automatedAssignedResources`. Cuando hagamos el campo requerido, TypeScript los marcará con error.
|
||||
|
||||
**Esta etapa cierra los tres pendientes del sprint: PDF correcto, campo promovido a requerido, tests del sprint.**
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
**F.1 — PDF recursos automatizados**
|
||||
Corregir `drawResourcesSection` para que cuando se exporta el escenario automatizado lea `automatedAssignedResources` (no `assignedResources`) al mostrar `utilizationMinutes` y `units` por fila.
|
||||
|
||||
**F.2 — Promover campo a requerido**
|
||||
Cambiar `automatedAssignedResources?: ActivityResourceAssignment[]` → `automatedAssignedResources: ActivityResourceAssignment[]` en `types.ts`. Corregir todos los factories en tests que crean `Activity` sin ese campo.
|
||||
|
||||
**F.3 — Tests nuevos del sprint**
|
||||
Tests unitarios del motor de simulación cubriendo los tres casos con `automatedAssignedResources`: solo fijo, solo recursos, mixto. Tests de `drawResourcesSection` para escenario automatizado.
|
||||
|
||||
---
|
||||
|
||||
## Cambios autorizados
|
||||
|
||||
### `src/domain/types.ts`
|
||||
|
||||
Remover el `?` del campo:
|
||||
|
||||
```typescript
|
||||
// ANTES:
|
||||
automatedAssignedResources?: ActivityResourceAssignment[] // 🆕 Sprint 3
|
||||
|
||||
// DESPUÉS:
|
||||
automatedAssignedResources: ActivityResourceAssignment[] // requerido desde Etapa 6
|
||||
```
|
||||
|
||||
Después de este cambio, TypeScript reportará errores en los factories de tests. Corregir todos los que aparezcan (ver sección de tests).
|
||||
|
||||
### `src/lib/export/pdf-sections.ts`
|
||||
|
||||
**Agregar parámetro `scenario` a `drawResourcesSection`:**
|
||||
|
||||
```typescript
|
||||
export function drawResourcesSection(
|
||||
doc: any,
|
||||
autoTable: Function,
|
||||
result: { perActivity: ActivitySimResult[] },
|
||||
resources: Resource[],
|
||||
activities: Activity[],
|
||||
currency: string,
|
||||
startY: number,
|
||||
scenario: 'actual' | 'automatizado' = 'actual' // 🆕 default 'actual' = retrocompatible
|
||||
): void
|
||||
```
|
||||
|
||||
**Corregir el lookup de asignación** (línea ~547 del archivo actual):
|
||||
|
||||
```typescript
|
||||
// ANTES (siempre usa AS-IS):
|
||||
const assignment = activityRow?.assignedResources.find((a) => a.resourceId === br.resourceId)
|
||||
|
||||
// DESPUÉS (usa el array del escenario correcto):
|
||||
const assignment = scenario === 'automatizado'
|
||||
? activityRow?.automatedAssignedResources?.find((a) => a.resourceId === br.resourceId)
|
||||
: activityRow?.assignedResources.find((a) => a.resourceId === br.resourceId)
|
||||
```
|
||||
|
||||
**Nota:** `automatedAssignedResources` puede ser `undefined` en actividades antiguas migradas sin pasar por ImportPage (aunque la migración Dexie v4 debería poblarlas con `[]`). El optional chaining `?.find` ya cubre ese caso.
|
||||
|
||||
El resto del cuerpo de `drawResourcesSection` no cambia.
|
||||
|
||||
### `src/lib/export/pdf-export.ts`
|
||||
|
||||
En `exportScenarioPdf`, actualizar la llamada a `drawResourcesSection` para pasar el `tab`:
|
||||
|
||||
```typescript
|
||||
// Antes:
|
||||
drawResourcesSection(docAny, autoTable, result, resources, activities, currency, y)
|
||||
|
||||
// Después:
|
||||
drawResourcesSection(docAny, autoTable, result, resources, activities, currency, y, tab)
|
||||
```
|
||||
|
||||
Esta es la única línea que cambia en este archivo.
|
||||
|
||||
---
|
||||
|
||||
## Tests — correcciones de factories
|
||||
|
||||
Cuando se promueva `automatedAssignedResources` a requerido, TypeScript reportará error en los siguientes archivos. Agregar `automatedAssignedResources: []` a cada factory que construye un `Activity`:
|
||||
|
||||
### `tests/domain/simulation.test.ts`
|
||||
|
||||
**Factory `makeAutoActivity`** (~línea 218): agregar campo:
|
||||
```typescript
|
||||
automatedCostFixed: opts.automatedCost ?? 0,
|
||||
automatedTimeMinutes: opts.automatedTime ?? 0,
|
||||
automatedAssignedResources: opts.automatedAssignedResources ?? [], // 🆕
|
||||
```
|
||||
|
||||
Si la interfaz de `opts` no incluye el campo, agregar:
|
||||
```typescript
|
||||
automatedAssignedResources?: ActivityResourceAssignment[]
|
||||
```
|
||||
|
||||
**Factory genérico** (~línea 77): agregar `automatedAssignedResources: []`.
|
||||
|
||||
**Factory `makeActivity` para recursos** (~línea 410): agregar `automatedAssignedResources: []`.
|
||||
|
||||
### `tests/integration/indexeddb-persistence.test.ts`
|
||||
|
||||
**`makeActivity`** (~línea 40): agregar `automatedAssignedResources: []` al objeto retornado.
|
||||
|
||||
### `tests/integration/sprint1-report-roi.test.ts`
|
||||
|
||||
**Factories inline** (~líneas 51, 311): agregar `automatedAssignedResources: []` a cada construcción de Activity.
|
||||
|
||||
### `tests/lib/export/pdf-resources-section.test.ts`
|
||||
|
||||
**`mockActivity`** (~línea 34): agregar `automatedAssignedResources: []`.
|
||||
|
||||
### `tests/integration/dexie-migration.test.ts`
|
||||
|
||||
**`makeLegacyActivity`** (~línea 19): usa `Omit<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 1–4) verdes.
|
||||
- [ ] 2 nuevos tests en `pdf-resources-section.test.ts` verdes.
|
||||
- [ ] Validación visual del director aprobada (PDF tab Automatizado con datos correctos).
|
||||
|
||||
---
|
||||
|
||||
## Reporte esperado al terminar
|
||||
|
||||
1. Resultado de `git diff --name-only HEAD`.
|
||||
2. Resultado de `npm run build` y `npm run test` (incluir count final de tests).
|
||||
3. Confirmación de los 3 casos de verificación funcional.
|
||||
4. Lista de factories de tests corregidos (archivo + línea aproximada).
|
||||
5. Cualquier decisión de iniciativa (Nivel 1/2/3).
|
||||
6. Si algún archivo de test tuvo más cambios de los esperados: declararlo.
|
||||
|
||||
---
|
||||
|
||||
## Referencias
|
||||
|
||||
- `sprints/sprint-3/BRIEF.md` — sección 4, Feature F
|
||||
- `src/lib/export/pdf-sections.ts` — `drawResourcesSection` (~línea 479), lookup de `assignedResources` (~línea 547)
|
||||
- `src/lib/export/pdf-export.ts` — `exportScenarioPdf` (~línea 54), llamada a `drawResourcesSection` (~línea 147)
|
||||
- `tests/domain/simulation.test.ts` — factories `makeAutoActivity` (~línea 218) y genérico (~línea 77)
|
||||
- `tests/lib/export/pdf-resources-section.test.ts` — `mockActivity` fixture (~línea 34)
|
||||
- `methodology/ANTI_PATTERNS.md` — AP.8 (adelanto silencioso), AP.9 (restricciones contradictorias)
|
||||
- `methodology/PATTERNS.md` — C.7 (git diff como control preventivo)
|
||||
267
sprints/sprint-3/ETAPA_7_PROMPT.md
Normal file
267
sprints/sprint-3/ETAPA_7_PROMPT.md
Normal 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 1–6 completadas. Sprint 3 funcionalmente completo.
|
||||
- La web app no muestra en ningún lugar el nombre del producto "InQ ROI" ni el logo de InQuality. El `AppFooter` existe pero tiene texto muy tenue (`text-slate-300`) que pasa desapercibido.
|
||||
- El favicon actual (`public/favicon.svg`) es un gráfico de barras azul genérico — no representa la marca InQ.
|
||||
- Los logos en PNG están en `assets/` en la raíz del proyecto. Vite sirve estáticos desde `public/`. Los archivos tienen espacios en el nombre y necesitan ser copiados con nombres limpios.
|
||||
|
||||
**Esta etapa es de branding puro. No hay lógica de negocio, no hay stores, no hay modelos.**
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Agregar identidad visual de InQ ROI en la web app:
|
||||
1. **Nuevo componente `AppHeader`:** barra naranja horizontal con logo InQuality en blanco y nombre del producto "InQ ROI". Aparece en todas las páginas principales.
|
||||
2. **Logos en `public/`:** copiar los PNGs relevantes con nombres sin espacios.
|
||||
3. **Favicon actualizado:** isotipo InQ naranja en lugar del gráfico de barras azul genérico.
|
||||
|
||||
---
|
||||
|
||||
## Paso 0 — Copiar logos a `public/`
|
||||
|
||||
Antes de cualquier código, copiar los siguientes archivos desde `assets/` a `public/`:
|
||||
|
||||
```bash
|
||||
# Ejecutar desde simulador-web/
|
||||
cp "assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 BCO.png" "public/inquality-logo-white.png"
|
||||
cp "assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png" "public/inquality-logo-color.png"
|
||||
```
|
||||
|
||||
Estos son archivos binarios — copiarlos sin modificar. Los nombres de destino no tienen espacios para compatibilidad con Vite.
|
||||
|
||||
---
|
||||
|
||||
## Cambios autorizados
|
||||
|
||||
### `public/inquality-logo-white.png` (NUEVO — binario copiado)
|
||||
### `public/inquality-logo-color.png` (NUEVO — binario copiado)
|
||||
|
||||
### `public/favicon.svg` (MODIFICAR)
|
||||
|
||||
Reemplazar el contenido actual con un favicon naranja InQ. Usar el isotipo simplificado — una "Q" con el naranja de marca en un fondo redondeado:
|
||||
|
||||
```svg
|
||||
<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)
|
||||
@@ -1,7 +1,7 @@
|
||||
export function AppFooter() {
|
||||
return (
|
||||
<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 🇵🇾
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
39
src/components/AppHeader.tsx
Normal file
39
src/components/AppHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -204,7 +204,11 @@ export function runSimulation(input: SimulationInput): SimulationResult {
|
||||
const resourceBreakdown: ActivitySimResult['resourceCostBreakdown'] = []
|
||||
let resourceCostDirect = 0
|
||||
|
||||
for (const assignment of activity.assignedResources) {
|
||||
const resourceAssignments = useAutomated
|
||||
? (activity.automatedAssignedResources ?? [])
|
||||
: activity.assignedResources
|
||||
|
||||
for (const assignment of resourceAssignments) {
|
||||
const resource = resources.get(assignment.resourceId)
|
||||
if (!resource) continue
|
||||
const hoursUsed = (assignment.utilizationMinutes / 60) * assignment.units
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface Process {
|
||||
automationInvestment: number // 🆕 Sprint 1 — inversión total del proyecto, default 0
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar")
|
||||
tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda
|
||||
}
|
||||
|
||||
export type ActivityType = 'task' | 'subprocess'
|
||||
@@ -32,6 +34,7 @@ export interface Activity {
|
||||
automatable: boolean // 🆕 Sprint 1 — si la actividad es candidata a automatización
|
||||
automatedCostFixed: number // 🆕 Sprint 1 — costo recurrente por ejecución automatizada
|
||||
automatedTimeMinutes: number // 🆕 Sprint 1 — tiempo de la actividad en escenario automatizado
|
||||
automatedAssignedResources: ActivityResourceAssignment[] // 🆕 Sprint 3 — recursos del escenario automatizado (requerido desde Etapa 6)
|
||||
}
|
||||
|
||||
export interface ActivityResourceAssignment {
|
||||
@@ -74,6 +77,20 @@ export interface Simulation {
|
||||
resultAutomated?: SimulationResult // 🆕 Sprint 1 — escenario automatizado (opcional por retrocompatibilidad)
|
||||
}
|
||||
|
||||
// ─── Process Library ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProcessGroup {
|
||||
id: UUID // generado con crypto.randomUUID()
|
||||
name: string
|
||||
createdAt: number // timestamp Unix ms
|
||||
updatedAt: number // timestamp Unix ms
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
id: 'singleton' // siempre un único registro por instalación
|
||||
groupLabel: string // etiqueta configurable para el nivel de agrupación. Default: 'Cliente'
|
||||
}
|
||||
|
||||
// ─── Motor de simulación: Input/Output ───────────────────────────────────────
|
||||
|
||||
// Escenario de simulación: actual (campos directCostFixed/executionTimeMinutes)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
@@ -9,6 +9,7 @@ import type { Process, Activity, GatewayConfig } from '@/domain/types'
|
||||
import { bpmnTypeToGatewayType } from '@/domain/types'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AppFooter } from '@/components/AppFooter'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
|
||||
const SAMPLE_PROCESSES = [
|
||||
{
|
||||
@@ -38,6 +39,10 @@ export function ImportPage() {
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [loadingSampleId, setLoadingSampleId] = useState<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) => {
|
||||
if (!file.name.endsWith('.bpmn') && !file.name.endsWith('.xml')) {
|
||||
toast({
|
||||
@@ -69,6 +74,8 @@ export function ImportPage() {
|
||||
automationInvestment: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
groupId: targetGroupId,
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const activityElements = extractActivityElements(graph)
|
||||
@@ -84,6 +91,7 @@ export function ImportPage() {
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
}))
|
||||
|
||||
const gatewayElements = extractGatewayElements(graph)
|
||||
@@ -167,7 +175,9 @@ export function ImportPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<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">
|
||||
@@ -278,6 +288,7 @@ export function ImportPage() {
|
||||
</p>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
621
src/features/library/LibraryPage.tsx
Normal file
621
src/features/library/LibraryPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ type SortKey = 'activityName' | 'expectedDirectCost' | 'expectedIndirectCost' |
|
||||
|
||||
interface ActivitiesTableProps {
|
||||
perActivity: ActivitySimResult[]
|
||||
pairedBreakdown?: Record<string, Array<{ resourceId: string; resourceName: string; cost: number }>>
|
||||
mode: HeatmapMode
|
||||
currency: string
|
||||
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" />
|
||||
}
|
||||
|
||||
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 [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||
@@ -167,23 +168,50 @@ export function ActivitiesTable({ perActivity, mode, currency, highlightedId, on
|
||||
<TableCell />
|
||||
<TableCell colSpan={7} className="py-2 pl-4 pr-6">
|
||||
<div className="space-y-1">
|
||||
{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>
|
||||
{pairedBreakdown?.[act.activityId] ? (
|
||||
<>
|
||||
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mt-1">AS-IS</p>
|
||||
{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>
|
||||
))}
|
||||
{pairedBreakdown[act.activityId].length === 0 && (
|
||||
<p className="text-xs text-slate-400 italic">Sin recursos AS-IS</p>
|
||||
)}
|
||||
<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>
|
||||
))
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams, useNavigate } from '@tanstack/react-router'
|
||||
import { AlertTriangle, Home, Info } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,6 +27,8 @@ import { TransparencySection } from './TransparencySection'
|
||||
import { AutomationScenarioNote } from './AutomationScenarioNote'
|
||||
import { MethodologyFooter } from './MethodologyFooter'
|
||||
import type { HeatmapMode } from '@/lib/colors'
|
||||
import { useSimulationStore } from '@/store/simulation-store'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
|
||||
export function ReportPage() {
|
||||
const { processId } = useParams({ from: '/report/$processId' })
|
||||
@@ -35,6 +37,12 @@ export function ReportPage() {
|
||||
|
||||
const { toast } = useToast()
|
||||
const [heatmapMode, setHeatmapMode] = useState<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
|
||||
const automatableIds = useMemo(
|
||||
@@ -56,6 +64,14 @@ export function ReportPage() {
|
||||
})
|
||||
}, [simulation, process])
|
||||
|
||||
// Breakdown del escenario actual, indexado por activityId, para comparación lado a lado en tab Automatizado
|
||||
const actualBreakdownById = useMemo(() => {
|
||||
if (!simulation?.result) return undefined
|
||||
return Object.fromEntries(
|
||||
simulation.result.perActivity.map((a) => [a.activityId, a.resourceCostBreakdown])
|
||||
)
|
||||
}, [simulation?.result])
|
||||
|
||||
// Estado y refs para el tab "Actual"
|
||||
const [selectedIdActual, setSelectedIdActual] = useState<string | null>(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.
|
||||
const hasAutomatedScenario = hasAutomatedResult && automatableIds.size > 0
|
||||
|
||||
const isStale = Boolean(process && process.updatedAt > (lastPersistedExecutedAt ?? 0))
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<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">
|
||||
|
||||
{/* ── Header (fuera de los tabs: aplica a todos los escenarios) ──── */}
|
||||
@@ -449,6 +485,7 @@ export function ReportPage() {
|
||||
</div>
|
||||
<ActivitiesTable
|
||||
perActivity={resultAutomated!.perActivity}
|
||||
pairedBreakdown={actualBreakdownById}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
highlightedId={selectedIdAuto}
|
||||
|
||||
@@ -58,9 +58,11 @@ function RoiKpiCard({
|
||||
export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
|
||||
const paybackStr = formatPayback(roi.paybackMonths)
|
||||
|
||||
const roiStr = !isFinite(roi.roiAnnualPercent)
|
||||
? '∞%'
|
||||
: formatPercent(roi.roiAnnualPercent)
|
||||
const roiIsUndefined = !isFinite(roi.roiAnnualPercent)
|
||||
const roiStr = roiIsUndefined ? '—' : 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
|
||||
? 'text-emerald-700'
|
||||
@@ -117,10 +119,10 @@ export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
|
||||
<RoiKpiCard
|
||||
label="ROI anual"
|
||||
value={roiStr}
|
||||
sub="retorno anual sobre la inversión"
|
||||
sub={roiSub}
|
||||
icon={<Percent className="h-4 w-4 text-amber-600" />}
|
||||
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>
|
||||
|
||||
82
src/features/settings/SettingsPage.tsx
Normal file
82
src/features/settings/SettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { PlusCircle, Trash2, Info, AlertTriangle } from 'lucide-react'
|
||||
import { PlusCircle, Trash2, Info, AlertTriangle, X } from 'lucide-react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -26,7 +26,11 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
|
||||
// Sincronizar con el store cuando cambia la selección
|
||||
useEffect(() => {
|
||||
setLocalActivity(activity ? { ...activity, assignedResources: [...activity.assignedResources] } : null)
|
||||
setLocalActivity(activity ? {
|
||||
...activity,
|
||||
assignedResources: [...activity.assignedResources],
|
||||
automatedAssignedResources: [...(activity.automatedAssignedResources ?? [])],
|
||||
} : null)
|
||||
setIsDirty(false)
|
||||
}, [selectedElementId, activities])
|
||||
|
||||
@@ -84,6 +88,36 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Handlers TO-BE ────────────────────────────────────────────────────────
|
||||
|
||||
function addAutomatedResource(resourceId: string) {
|
||||
if (!localActivity) return
|
||||
const already = (localActivity.automatedAssignedResources ?? []).some((r) => r.resourceId === resourceId)
|
||||
if (already) return
|
||||
handleChange('automatedAssignedResources', [
|
||||
...(localActivity.automatedAssignedResources ?? []),
|
||||
{ resourceId, utilizationMinutes: 60, units: 1 },
|
||||
])
|
||||
}
|
||||
|
||||
function removeAutomatedResource(resourceId: string) {
|
||||
if (!localActivity) return
|
||||
handleChange(
|
||||
'automatedAssignedResources',
|
||||
(localActivity.automatedAssignedResources ?? []).filter((r) => r.resourceId !== resourceId)
|
||||
)
|
||||
}
|
||||
|
||||
function updateAutomatedAssignment(resourceId: string, field: 'utilizationMinutes' | 'units', value: number) {
|
||||
if (!localActivity) return
|
||||
handleChange(
|
||||
'automatedAssignedResources',
|
||||
(localActivity.automatedAssignedResources ?? []).map((r) =>
|
||||
r.resourceId === resourceId ? { ...r, [field]: value } : r
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (!selectedElementId) {
|
||||
return (
|
||||
<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 availableAutomatedResources = resources.filter(
|
||||
(r) => !(localActivity.automatedAssignedResources ?? []).some((ar) => ar.resourceId === r.id)
|
||||
)
|
||||
|
||||
const currency = currentProcess?.currency ?? 'USD'
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-5">
|
||||
@@ -370,7 +410,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="space-y-1.5">
|
||||
@@ -417,6 +457,101 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
/>
|
||||
</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
|
||||
al tipear un valor (no desaparece instantáneo sino con fade).
|
||||
La condición controla opacity + max-h, no mount/unmount. */}
|
||||
@@ -424,6 +559,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
const showHelper = localActivity.automatable
|
||||
&& localActivity.automatedCostFixed === 0
|
||||
&& localActivity.automatedTimeMinutes === 0
|
||||
&& (localActivity.automatedAssignedResources ?? []).length === 0
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!showHelper}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import BpmnViewer from 'bpmn-js/lib/Viewer'
|
||||
import { Maximize2 } from 'lucide-react'
|
||||
|
||||
export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
|
||||
|
||||
@@ -152,5 +153,34 @@ export function BpmnCanvas({
|
||||
}
|
||||
}, [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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo, useDeferredValue } from 'react'
|
||||
import { useParams, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Loader2, BarChart3, Play, AlertTriangle,
|
||||
Loader2, BarChart3, Play, AlertTriangle, AlertCircle,
|
||||
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2,
|
||||
Check, Pencil
|
||||
} from 'lucide-react'
|
||||
@@ -18,6 +18,8 @@ import { GlobalSettingsPanel } from './GlobalSettingsPanel'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { useSimulationStore } from '@/store/simulation-store'
|
||||
import { useSimulate } from '../simulation/useSimulate'
|
||||
import { useLibraryStore } from '@/store/library-store'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
|
||||
type SelectedCategory = 'activity' | 'gateway' | null
|
||||
|
||||
@@ -25,7 +27,7 @@ export function WorkspacePage() {
|
||||
const { processId } = useParams({ from: '/workspace/$processId' })
|
||||
const navigate = useNavigate()
|
||||
const { currentProcess, activities, isLoading, error, loadProcess, gateways, updateProcess } = useProcessStore()
|
||||
const { isRunning, resultActual, error: simError, selectActivity } = useSimulationStore()
|
||||
const { isRunning, resultActual, error: simError, selectActivity, loadLatestForProcess, lastPersistedExecutedAt } = useSimulationStore()
|
||||
const { simulate } = useSimulate()
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -41,6 +43,11 @@ export function WorkspacePage() {
|
||||
const [savedField, setSavedField] = useState<'name' | 'client' | null>(null)
|
||||
const savedTimerRef = useRef<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.
|
||||
// Esto evita que el redirect se dispare en el render inicial donde
|
||||
// isLoading=false y currentProcess=null (antes del primer efecto).
|
||||
@@ -48,6 +55,11 @@ export function WorkspacePage() {
|
||||
|
||||
useEffect(() => { loadProcess(processId) }, [processId, loadProcess])
|
||||
|
||||
// Cargar el último executedAt persistido para comparar contra updatedAt (indicador stale)
|
||||
useEffect(() => {
|
||||
if (currentProcess) loadLatestForProcess(currentProcess.id)
|
||||
}, [currentProcess?.id, loadLatestForProcess])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) { hasLoadedOnce.current = true; return }
|
||||
if (!hasLoadedOnce.current) return // carga no iniciada aún
|
||||
@@ -71,6 +83,12 @@ export function WorkspacePage() {
|
||||
|
||||
const canSimulate = !isRunning && xorValidation.valid
|
||||
|
||||
// Hay cambios sin simular cuando updatedAt supera al executedAt de la última simulación persistida
|
||||
const isStale = Boolean(
|
||||
currentProcess &&
|
||||
currentProcess.updatedAt > (lastPersistedExecutedAt ?? 0)
|
||||
)
|
||||
|
||||
// IDs de actividades marcadas como automatizables — alimenta los overlays del canvas.
|
||||
// useDeferredValue evita re-renders en cascada mientras el usuario edita el panel.
|
||||
const automatableElementIds = useMemo(
|
||||
@@ -118,6 +136,26 @@ export function WorkspacePage() {
|
||||
savedTimerRef.current = setTimeout(() => setSavedField(null), 1500)
|
||||
}
|
||||
|
||||
function addTag(raw: string) {
|
||||
const normalized = raw.trim().toLowerCase()
|
||||
if (!normalized) return
|
||||
const current = currentProcess?.tags ?? []
|
||||
if (current.some((t) => t === normalized)) return
|
||||
updateProcess({ tags: [...current, normalized] })
|
||||
setTagInput('')
|
||||
setShowTagSuggestions(false)
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
const current = currentProcess?.tags ?? []
|
||||
updateProcess({ tags: current.filter((t) => t !== tag) })
|
||||
}
|
||||
|
||||
function handleTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); if (tagInput.trim()) addTag(tagInput) }
|
||||
if (e.key === 'Escape') { setTagInput(''); setShowTagSuggestions(false) }
|
||||
}
|
||||
|
||||
async function handleSimulate() {
|
||||
if (!canSimulate) return
|
||||
const simResult = await simulate()
|
||||
@@ -147,9 +185,10 @@ export function WorkspacePage() {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="h-screen flex flex-col bg-slate-100 overflow-hidden">
|
||||
<AppHeader />
|
||||
|
||||
{/* ── 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>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navigate({ to: '/' })}>
|
||||
@@ -219,6 +258,59 @@ export function WorkspacePage() {
|
||||
</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>
|
||||
|
||||
{/* Errores de simulación */}
|
||||
@@ -261,6 +353,15 @@ export function WorkspacePage() {
|
||||
</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 */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -144,7 +144,7 @@ async function exportScenarioPdf(
|
||||
if (hasResources) {
|
||||
docAny.addPage('a4', 'portrait')
|
||||
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 4 })
|
||||
drawResourcesSection(docAny, autoTable, result, resources, activities, currency, y)
|
||||
drawResourcesSection(docAny, autoTable, result, resources, activities, currency, y, tab)
|
||||
}
|
||||
|
||||
// ── Página 4 o 5: Nota metodológica ──────────────────────────────────────────
|
||||
|
||||
@@ -485,7 +485,8 @@ export function drawResourcesSection(
|
||||
resources: Resource[],
|
||||
activities: Activity[],
|
||||
currency: string,
|
||||
startY: number
|
||||
startY: number,
|
||||
scenario: 'actual' | 'automatizado' = 'actual'
|
||||
): void {
|
||||
let y = startY
|
||||
const m = PDF.margin
|
||||
@@ -544,7 +545,9 @@ export function drawResourcesSection(
|
||||
|
||||
for (const br of act.resourceCostBreakdown) {
|
||||
const resource = resourceMap.get(br.resourceId)
|
||||
const assignment = activityRow?.assignedResources.find((a) => a.resourceId === br.resourceId)
|
||||
const assignment = scenario === 'automatizado'
|
||||
? activityRow?.automatedAssignedResources?.find((a) => a.resourceId === br.resourceId)
|
||||
: activityRow?.assignedResources.find((a) => a.resourceId === br.resourceId)
|
||||
|
||||
const typeName = resource ? (TYPE_LABEL[resource.type] ?? resource.type) : '—'
|
||||
const typeColor = resource ? (TYPE_COLOR[resource.type] ?? PDF.slate50) : PDF.slate50
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Dexie, { type Table } from 'dexie'
|
||||
import type { Process, Activity, GatewayConfig, Resource, Simulation } from '@/domain/types'
|
||||
import type { Process, Activity, GatewayConfig, Resource, Simulation, ProcessGroup, AppSettings } from '@/domain/types'
|
||||
|
||||
export class ProcessCostDb extends Dexie {
|
||||
processes!: Table<Process>
|
||||
@@ -7,6 +7,8 @@ export class ProcessCostDb extends Dexie {
|
||||
gateways!: Table<GatewayConfig>
|
||||
resources!: Table<Resource>
|
||||
simulations!: Table<Simulation>
|
||||
groups!: Table<ProcessGroup> // 🆕 Sprint 3
|
||||
settings!: Table<AppSettings> // 🆕 Sprint 3
|
||||
|
||||
constructor() {
|
||||
super('ProcessCostPlatform')
|
||||
@@ -79,6 +81,44 @@ export class ProcessCostDb extends Dexie {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Sprint 3: agrega tabla de grupos (Process Library), tabla de settings,
|
||||
// campos groupId/tags en Process, y automatedAssignedResources en Activity.
|
||||
this.version(4)
|
||||
.stores({
|
||||
processes: 'id, name, updatedAt, groupId', // groupId indexado para queries por grupo
|
||||
activities: 'id, processId, bpmnElementId',
|
||||
gateways: 'id, processId, bpmnElementId',
|
||||
resources: 'id, processId, name',
|
||||
simulations: 'id, processId, executedAt',
|
||||
groups: 'id, name, updatedAt', // nueva tabla
|
||||
settings: 'id', // nueva tabla (singleton)
|
||||
})
|
||||
.upgrade(async (tx) => {
|
||||
// Process: agregar groupId (null) y tags ([])
|
||||
await tx.table('processes').toCollection().modify((proc) => {
|
||||
if (proc.groupId === undefined) proc.groupId = null
|
||||
if (!Array.isArray(proc.tags)) proc.tags = []
|
||||
})
|
||||
// Activity: agregar automatedAssignedResources ([])
|
||||
await tx.table('activities').toCollection().modify((act) => {
|
||||
if (!Array.isArray(act.automatedAssignedResources)) {
|
||||
act.automatedAssignedResources = []
|
||||
}
|
||||
})
|
||||
// Settings: crear singleton con defaults (put es idempotente por clave primaria)
|
||||
await tx.table('settings').put({ id: 'singleton', groupLabel: 'Cliente' })
|
||||
})
|
||||
|
||||
// Belt-and-suspenders: garantizar singleton en fresh installs y entornos de test.
|
||||
// El upgrade también lo crea; este handler cubre el caso en que la tabla sea nueva
|
||||
// pero el upgrade no haya persistido el put (conocido con fake-indexeddb).
|
||||
this.on('ready', async () => {
|
||||
const existing = await this.settings.get('singleton')
|
||||
if (!existing) {
|
||||
await this.settings.put({ id: 'singleton', groupLabel: 'Cliente' })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import { db } from './db'
|
||||
import type { Process, Activity, GatewayConfig, Resource, Simulation } from '@/domain/types'
|
||||
import type { Process, Activity, GatewayConfig, Resource, Simulation, ProcessGroup, AppSettings } from '@/domain/types'
|
||||
|
||||
// ─── Process ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -125,13 +125,56 @@ export const simulationRepo = {
|
||||
},
|
||||
|
||||
async getLatestByProcess(processId: string): Promise<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')
|
||||
.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[]> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { lazy, Suspense } from 'react'
|
||||
import { createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/react-router'
|
||||
import { WorkspacePage } from '@/features/workspace/WorkspacePage'
|
||||
import { ImportPage } from '@/features/import/ImportPage'
|
||||
import { LibraryPage } from '@/features/library/LibraryPage'
|
||||
import { SettingsPage } from '@/features/settings/SettingsPage'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
// ECharts es grande (373KB gzip). Lazy-load del ReportPage para que el
|
||||
@@ -28,6 +30,12 @@ const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: LibraryPage,
|
||||
})
|
||||
|
||||
const importRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/import',
|
||||
component: ImportPage,
|
||||
})
|
||||
|
||||
@@ -43,7 +51,13 @@ const reportRoute = createRoute({
|
||||
component: ReportPageWithSuspense,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, workspaceRoute, reportRoute])
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/settings',
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, importRoute, workspaceRoute, reportRoute, settingsRoute])
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
||||
98
src/store/library-store.ts
Normal file
98
src/store/library-store.ts
Normal 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()
|
||||
},
|
||||
}))
|
||||
@@ -66,33 +66,80 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
||||
|
||||
updateActivity: async (activity) => {
|
||||
await activityRepo.save(activity)
|
||||
set((state) => ({
|
||||
activities: state.activities.map((a) => (a.id === activity.id ? activity : a)),
|
||||
}))
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
await processRepo.save(updatedProcess)
|
||||
set((state) => ({
|
||||
activities: state.activities.map((a) => (a.id === activity.id ? activity : a)),
|
||||
currentProcess: updatedProcess,
|
||||
}))
|
||||
} else {
|
||||
set((state) => ({
|
||||
activities: state.activities.map((a) => (a.id === activity.id ? activity : a)),
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
updateGateway: async (gateway) => {
|
||||
await gatewayRepo.save(gateway)
|
||||
set((state) => ({
|
||||
gateways: state.gateways.map((g) => (g.id === gateway.id ? gateway : g)),
|
||||
}))
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
await processRepo.save(updatedProcess)
|
||||
set((state) => ({
|
||||
gateways: state.gateways.map((g) => (g.id === gateway.id ? gateway : g)),
|
||||
currentProcess: updatedProcess,
|
||||
}))
|
||||
} else {
|
||||
set((state) => ({
|
||||
gateways: state.gateways.map((g) => (g.id === gateway.id ? gateway : g)),
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
addResource: async (resource) => {
|
||||
await resourceRepo.save(resource)
|
||||
set((state) => ({ resources: [...state.resources, resource] }))
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
await processRepo.save(updatedProcess)
|
||||
set((state) => ({ resources: [...state.resources, resource], currentProcess: updatedProcess }))
|
||||
} else {
|
||||
set((state) => ({ resources: [...state.resources, resource] }))
|
||||
}
|
||||
},
|
||||
|
||||
updateResource: async (resource) => {
|
||||
await resourceRepo.save(resource)
|
||||
set((state) => ({
|
||||
resources: state.resources.map((r) => (r.id === resource.id ? resource : r)),
|
||||
}))
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
await processRepo.save(updatedProcess)
|
||||
set((state) => ({
|
||||
resources: state.resources.map((r) => (r.id === resource.id ? resource : r)),
|
||||
currentProcess: updatedProcess,
|
||||
}))
|
||||
} else {
|
||||
set((state) => ({
|
||||
resources: state.resources.map((r) => (r.id === resource.id ? resource : r)),
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
deleteResource: async (resourceId) => {
|
||||
await resourceRepo.delete(resourceId)
|
||||
set((state) => ({ resources: state.resources.filter((r) => r.id !== resourceId) }))
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
await processRepo.save(updatedProcess)
|
||||
set((state) => ({
|
||||
resources: state.resources.filter((r) => r.id !== resourceId),
|
||||
currentProcess: updatedProcess,
|
||||
}))
|
||||
} else {
|
||||
set((state) => ({ resources: state.resources.filter((r) => r.id !== resourceId) }))
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => set({ currentProcess: null, activities: [], gateways: [], resources: [], error: null }),
|
||||
|
||||
@@ -11,6 +11,7 @@ interface SimulationState {
|
||||
resultActual: SimulationResult | null // resultado del escenario actual
|
||||
resultAutomated: SimulationResult | null // resultado del escenario automatizado
|
||||
lastSimulatedAt: Date | null // timestamp de la última simulación exitosa
|
||||
lastPersistedExecutedAt: number | null // executedAt de la última sim guardada en IndexedDB (sobrevive recargas)
|
||||
isRunning: boolean
|
||||
error: string | null
|
||||
selectedActivityId: string | null
|
||||
@@ -24,25 +25,34 @@ interface SimulationState {
|
||||
// Invalida AMBOS resultados a null. Se llama cuando cambian datos del proceso.
|
||||
invalidateResults: () => void
|
||||
reset: () => void
|
||||
loadLatestForProcess: (processId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSimulationStore = create<SimulationState>((set) => ({
|
||||
resultActual: null,
|
||||
resultAutomated: null,
|
||||
lastSimulatedAt: null,
|
||||
lastPersistedExecutedAt: null,
|
||||
isRunning: false,
|
||||
error: null,
|
||||
selectedActivityId: null,
|
||||
|
||||
persistResults: async (processId, actual, automated) => {
|
||||
const executedAt = Date.now()
|
||||
await simulationRepo.save({
|
||||
id: uuidv4(),
|
||||
processId,
|
||||
executedAt: Date.now(),
|
||||
executedAt,
|
||||
result: actual,
|
||||
resultAutomated: automated,
|
||||
})
|
||||
set({ resultActual: actual, resultAutomated: automated, lastSimulatedAt: new Date(), error: null })
|
||||
set({
|
||||
resultActual: actual,
|
||||
resultAutomated: automated,
|
||||
lastSimulatedAt: new Date(),
|
||||
lastPersistedExecutedAt: executedAt,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
|
||||
setRunning: (isRunning) => set({ isRunning }),
|
||||
@@ -57,10 +67,16 @@ export const useSimulationStore = create<SimulationState>((set) => ({
|
||||
resultActual: null,
|
||||
resultAutomated: null,
|
||||
lastSimulatedAt: null,
|
||||
lastPersistedExecutedAt: null,
|
||||
isRunning: false,
|
||||
error: null,
|
||||
selectedActivityId: null,
|
||||
}),
|
||||
|
||||
loadLatestForProcess: async (processId) => {
|
||||
const sim = await simulationRepo.getLatestByProcess(processId)
|
||||
set({ lastPersistedExecutedAt: sim?.executedAt ?? null })
|
||||
},
|
||||
}))
|
||||
|
||||
// Suscripción reactiva: cualquier cambio en activities o currentProcess del process-store
|
||||
|
||||
99
tests/domain/simulation-sprint3.test.ts
Normal file
99
tests/domain/simulation-sprint3.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -92,6 +92,7 @@ describe('runSimulation', () => {
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
})
|
||||
|
||||
it('calcula costo total sin recursos — proceso lineal', () => {
|
||||
@@ -220,6 +221,7 @@ describe('runSimulation — scenario automated', () => {
|
||||
opts: {
|
||||
directCost?: number; directTime?: number
|
||||
automatable?: boolean; automatedCost?: number; automatedTime?: number
|
||||
automatedAssignedResources?: import('@/domain/types').ActivityResourceAssignment[]
|
||||
} = {}
|
||||
): Activity => ({
|
||||
id: `act-${bpmnElementId}`,
|
||||
@@ -233,6 +235,7 @@ describe('runSimulation — scenario automated', () => {
|
||||
automatable: opts.automatable ?? false,
|
||||
automatedCostFixed: opts.automatedCost ?? 0,
|
||||
automatedTimeMinutes: opts.automatedTime ?? 0,
|
||||
automatedAssignedResources: opts.automatedAssignedResources ?? [],
|
||||
})
|
||||
|
||||
// ─── scenario=automated con costos en cero ────────────────────────────────
|
||||
@@ -409,6 +412,7 @@ describe('runSimulation — recursos con modelo utilizationMinutes + units', ()
|
||||
directCostFixed: directCost, executionTimeMinutes: minutes,
|
||||
assignedResources: resources,
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
})
|
||||
|
||||
it('actividad sin recursos: costo = directCostFixed', () => {
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('MethodologyFooter', () => {
|
||||
id: 'p1', name: 'Proceso Test', clientName: '',
|
||||
bpmnXml: '<def/>', currency: 'USD',
|
||||
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()
|
||||
})
|
||||
@@ -237,7 +237,7 @@ describe('MethodologyFooter', () => {
|
||||
id: 'p1', name: 'Proc', clientName: '',
|
||||
bpmnXml: '', currency: 'USD',
|
||||
overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [],
|
||||
}
|
||||
render(<MethodologyFooter process={proc} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
@@ -249,7 +249,7 @@ describe('MethodologyFooter', () => {
|
||||
id: 'p1', name: 'Proc', clientName: '',
|
||||
bpmnXml: '', currency: 'USD',
|
||||
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [],
|
||||
}
|
||||
render(<MethodologyFooter process={proc} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
@@ -334,6 +334,8 @@ describe('ReportPage — con datos completos', () => {
|
||||
automationInvestment: 0,
|
||||
createdAt: Date.now() - 1000 * 60 * 30,
|
||||
updatedAt: Date.now() - 1000 * 60 * 30,
|
||||
groupId: null,
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const mockSimulation = {
|
||||
|
||||
@@ -31,7 +31,7 @@ function buildSimInput(xml: string, directCost: number, overhead: number, curren
|
||||
directCostFixed: directCost,
|
||||
executionTimeMinutes: 30,
|
||||
assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
|
||||
}])
|
||||
)
|
||||
|
||||
|
||||
@@ -157,9 +157,10 @@ describe('RoiKpiBar', () => {
|
||||
expect(screen.getByText(/Inmediato/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ROI Infinity (inversión cero) → muestra "∞%"', () => {
|
||||
it('ROI Infinity (inversión cero) → muestra "—" con texto accionable', () => {
|
||||
render(<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', () => {
|
||||
@@ -311,6 +312,7 @@ const makeActivityDomain = (id: string, name: string, automatable: boolean, zero
|
||||
automatable,
|
||||
automatedCostFixed: zeroCosts ? 0 : 50,
|
||||
automatedTimeMinutes: zeroCosts ? 0 : 10,
|
||||
automatedAssignedResources: [],
|
||||
})
|
||||
|
||||
describe('TransparencySection', () => {
|
||||
@@ -586,6 +588,7 @@ const mockProcessBase = {
|
||||
currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||
createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const mockSimulationBase = {
|
||||
@@ -598,6 +601,7 @@ const automtableActivity: Activity = {
|
||||
id: 'act-1', processId: 'test-proc-id', bpmnElementId: 'bpmn_a1', name: 'Recibir solicitud',
|
||||
type: 'task', directCostFixed: 500, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable: true, automatedCostFixed: 50, automatedTimeMinutes: 5,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
|
||||
const nonAutomatableActivity: Activity = {
|
||||
|
||||
@@ -32,6 +32,7 @@ function makeActivity(overrides: Partial<Activity> & { id: string; processId: st
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { Activity, Process } from '@/domain/types'
|
||||
|
||||
// ─── Fixtures de registros "viejos" (sin campos Sprint 1) ─────────────────────
|
||||
|
||||
function makeLegacyActivity(id: string, processId: string): Omit<Activity, 'automatable' | 'automatedCostFixed' | 'automatedTimeMinutes'> {
|
||||
function makeLegacyActivity(id: string, processId: string): Omit<Activity, 'automatable' | 'automatedCostFixed' | 'automatedTimeMinutes' | 'automatedAssignedResources'> {
|
||||
return {
|
||||
id, processId,
|
||||
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 {
|
||||
id, name: 'Proceso Legacy', clientName: 'Cliente Viejo',
|
||||
bpmnXml: '<definitions/>', currency: 'USD', overheadPercentage: 0.15,
|
||||
@@ -59,6 +59,7 @@ describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () =>
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
await activityRepo.save(act)
|
||||
const retrieved = await activityRepo.getByProcess('p1')
|
||||
@@ -73,6 +74,7 @@ describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () =>
|
||||
automatable: true,
|
||||
automatedCostFixed: 12.5,
|
||||
automatedTimeMinutes: 3,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
await activityRepo.save(act)
|
||||
const [retrieved] = await activityRepo.getByProcess('p1')
|
||||
@@ -87,6 +89,7 @@ describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () =>
|
||||
automatable: true,
|
||||
automatedCostFixed: 5,
|
||||
automatedTimeMinutes: 10,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
await activityRepo.save(act)
|
||||
// Actualizar solo directCostFixed — los campos de automatización no deben cambiar
|
||||
@@ -105,6 +108,7 @@ describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => {
|
||||
annualFrequency: 1000,
|
||||
analysisHorizonYears: 3,
|
||||
automationInvestment: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
await processRepo.save(proc)
|
||||
const retrieved = await processRepo.getById('proc-1')
|
||||
@@ -119,6 +123,7 @@ describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => {
|
||||
annualFrequency: 5000,
|
||||
analysisHorizonYears: 5,
|
||||
automationInvestment: 75_000,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
await processRepo.save(proc)
|
||||
const retrieved = await processRepo.getById('proc-2')
|
||||
@@ -133,6 +138,7 @@ describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => {
|
||||
annualFrequency: 2000,
|
||||
analysisHorizonYears: 4,
|
||||
automationInvestment: 30_000,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
await processRepo.save(proc)
|
||||
// Actualizar solo el nombre — los campos de volumetría no deben cambiar
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('motor / propagación con GatewayConfig correcta', () => {
|
||||
actIds.map((id) => [id, {
|
||||
id: uuidv4(), processId, bpmnElementId: id, name: id,
|
||||
type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
|
||||
}])
|
||||
)
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ function makeProcess(id = 'proc-1'): Process {
|
||||
automationInvestment: 0,
|
||||
createdAt: 1000,
|
||||
updatedAt: 2000,
|
||||
groupId: null,
|
||||
tags: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +50,7 @@ function makeActivity(id: string, processId: string, bpmnElementId: string): Act
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ function buildInput(
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
@@ -51,6 +51,7 @@ function buildActivities(xml: string): Map<string, Activity> {
|
||||
automatable: AUTOMATABLE_IDS.has(el.bpmnElementId),
|
||||
automatedCostFixed: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_COST : 0,
|
||||
automatedTimeMinutes: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_TIME : 0,
|
||||
automatedAssignedResources: [],
|
||||
}])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ function buildSimInput(
|
||||
automatable: automatableIds.has(el.bpmnElementId),
|
||||
automatedCostFixed: automatableIds.has(el.bpmnElementId) ? automatedCost : 0,
|
||||
automatedTimeMinutes: automatableIds.has(el.bpmnElementId) ? automatedTime : 0,
|
||||
automatedAssignedResources: [],
|
||||
}])
|
||||
)
|
||||
|
||||
@@ -312,6 +313,7 @@ describe('Caso C — medium-with-gateways, 1 automatable en ceros + investment=0
|
||||
automatable: el.bpmnElementId === 'task_valid',
|
||||
automatedCostFixed: 0, // cero — trigger de transparencia
|
||||
automatedTimeMinutes: 0, // cero — trigger de transparencia
|
||||
automatedAssignedResources: [],
|
||||
}])
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ function makeProcess(extras: Partial<Process> = {}): Process {
|
||||
id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ',
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||
createdAt: 0, updatedAt: 0, ...extras,
|
||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ...extras,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,11 +66,13 @@ const mockActivities: Activity[] = [
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task',
|
||||
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5,
|
||||
automatedAssignedResources: [],
|
||||
},
|
||||
{
|
||||
id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task',
|
||||
directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ function makeProcess(currency: string, extras: Partial<Process> = {}): Process {
|
||||
id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC',
|
||||
bpmnXml: '', currency, overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0, ...extras,
|
||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ...extras,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ function buildInput(xml: string, directCost: number): SimulationInput {
|
||||
actEls.map((el) => [el.bpmnElementId, {
|
||||
id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, name: el.name,
|
||||
type: el.type, directCostFixed: directCost, executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
|
||||
}])
|
||||
)
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('Medición de tamaños de CSV — 3 sample BPMNs', () => {
|
||||
bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const simulation: Simulation = {
|
||||
|
||||
@@ -51,6 +51,7 @@ const mockProcess: Process = {
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const perActivityActual = [
|
||||
@@ -101,11 +102,13 @@ const mockActivities: Activity[] = [
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task',
|
||||
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
|
||||
automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5,
|
||||
automatedAssignedResources: [],
|
||||
},
|
||||
{
|
||||
id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task',
|
||||
directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ const mockProcess: Process = {
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
// Simulación SIN recursos en ninguna actividad
|
||||
@@ -72,6 +73,7 @@ const mockActivity: Activity = {
|
||||
directCostFixed: 200, executionTimeMinutes: 60,
|
||||
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
}
|
||||
|
||||
const mockSimWithResources: Simulation = {
|
||||
|
||||
@@ -54,6 +54,7 @@ const mockProcess: Process = {
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
|
||||
@@ -59,6 +59,7 @@ const mockProcess: Process = {
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
|
||||
@@ -30,6 +30,7 @@ const mockActivity: Activity = {
|
||||
directCostFixed: 600, executionTimeMinutes: 60,
|
||||
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [{ resourceId: 'r1', utilizationMinutes: 5, units: 3 }],
|
||||
}
|
||||
|
||||
const activityWithResources: ActivitySimResult = {
|
||||
@@ -122,3 +123,43 @@ describe('drawResourcesSection', () => {
|
||||
expect(autoTableMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Escenario automatizado usa automatedAssignedResources ────────────────────
|
||||
|
||||
describe('drawResourcesSection — escenario automatizado', () => {
|
||||
let autoTableMock: ReturnType<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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,6 +56,7 @@ const mockProcess: Process = {
|
||||
overheadPercentage: 0.2,
|
||||
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
|
||||
@@ -52,6 +52,7 @@ const mockProcess: Process = {
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const mockRoi: RoiResult = {
|
||||
@@ -103,10 +104,10 @@ const mockSimulation: Simulation = {
|
||||
const mockActivities: Activity[] = [
|
||||
{ id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'Tarea A', type: 'task',
|
||||
automatable: true, directCostFixed: 400, executionTimeMinutes: 30,
|
||||
automatedCostFixed: 40, automatedTimeMinutes: 5, assignedResources: [] },
|
||||
automatedCostFixed: 40, automatedTimeMinutes: 5, assignedResources: [], automatedAssignedResources: [] },
|
||||
{ id: 'a2', processId: 'p1', bpmnElementId: 't2', name: 'Tarea B', type: 'task',
|
||||
automatable: false, directCostFixed: 200, executionTimeMinutes: 20,
|
||||
automatedCostFixed: 0, automatedTimeMinutes: 0, assignedResources: [] },
|
||||
automatedCostFixed: 0, automatedTimeMinutes: 0, assignedResources: [], automatedAssignedResources: [] },
|
||||
]
|
||||
|
||||
describe('PDF ROI — Etapa 7 páginas 2-5', () => {
|
||||
|
||||
@@ -52,6 +52,7 @@ const mockProcess: Process = {
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
|
||||
const mockSimulation: Simulation = {
|
||||
|
||||
220
tests/persistence/migration-v4.test.ts
Normal file
220
tests/persistence/migration-v4.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -94,6 +94,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
|
||||
currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000,
|
||||
createdAt: Date.now(), updatedAt: Date.now(),
|
||||
groupId: null, tags: [],
|
||||
}
|
||||
useProcessStore.setState({ currentProcess: proc })
|
||||
|
||||
@@ -110,7 +111,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
|
||||
type: 'task',
|
||||
directCostFixed: 999, // campo modificado
|
||||
executionTimeMinutes: 30, assignedResources: [],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
|
||||
}]
|
||||
useProcessStore.setState({ activities: newActivities })
|
||||
|
||||
@@ -122,7 +123,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
|
||||
const activities: Activity[] = [{
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'T',
|
||||
type: 'task', directCostFixed: 100, executionTimeMinutes: 30,
|
||||
assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [],
|
||||
}]
|
||||
|
||||
useProcessStore.setState({ activities })
|
||||
|
||||
Reference in New Issue
Block a user