feat: MVP Fase 0 — Process Cost Platform v0.1.0

Plataforma completa de análisis de costos operativos basada en BPMN 2.0.

Funcionalidades incluidas:
- Import de archivos BPMN 2.0 por drag & drop + 3 procesos de ejemplo
- Visualización read-only del diagrama (bpmn-js)
- Configuración de actividades (costo, tiempo, recursos asignados)
- CRUD de recursos (rol, persona, sistema, equipo, insumo)
- Configuración global (moneda, overhead %, nombre, cliente)
- Motor de simulación determinístico agregado con propagación de gateways XOR/AND
- Reporte visual con mapa de calor en dos modos (relativo/absoluto)
- Gráficos: KPIs, top actividades, composición, costo por recurso
- Export PDF (jsPDF + html2canvas) y CSV (papaparse)
- Persistencia en IndexedDB (Dexie)
- 268 tests unitarios/integración + 6 E2E con Playwright
- Deploy: https://process-cost-platform.pages.dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 01:58:40 -03:00
commit bed161c92a
97 changed files with 19638 additions and 0 deletions

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Playwright outputs
tests/e2e/__output__/
test-results/
# Wrangler
.wrangler/

835
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,835 @@
# Documento de Arquitectura
## Plataforma de Diseño, Mapeo y Cuantificación de Procesos de Negocio
> **Mantra**: *Los procesos de negocio son activos estratégicos de las empresas y un diferencial competitivo.*
| Atributo | Valor |
|---|---|
| Versión del documento | 0.1.0 |
| Fecha | Mayo 2026 |
| Estado | Draft — Fase 0 (MVP "Noche Cero") en construcción |
| Autor | Equipo fundador + Claude (arquitectura) |
| Alcance | MVP de esta noche + Norte V1.0 (1218 meses) |
---
## Tabla de Contenidos
1. [Contexto y Propuesta de Valor](#1-contexto-y-propuesta-de-valor)
2. [Glosario y Convenciones](#2-glosario-y-convenciones)
3. [Norte Estratégico — Visión V1.0](#3-norte-estratégico--visión-v10)
4. [Fase 0 — MVP "Noche Cero"](#4-fase-0--mvp-noche-cero)
5. [Roadmap de Evolución](#5-roadmap-de-evolución)
6. [Arquitectura Técnica](#6-arquitectura-técnica)
7. [Modelo de Dominio](#7-modelo-de-dominio)
8. [Decisiones Arquitectónicas (ADRs)](#8-decisiones-arquitectónicas-adrs)
9. [Atributos de Calidad](#9-atributos-de-calidad)
10. [Riesgos y Mitigaciones](#10-riesgos-y-mitigaciones)
11. [Anexos](#11-anexos)
---
## 1. Contexto y Propuesta de Valor
### 1.1 Problema que resolvemos
Las consultoras, gerentes de operaciones y profesionales de eficiencia operativa carecen de una herramienta accesible que les permita **cuantificar de forma objetiva, visual y comunicable el costo operativo real de un proceso de negocio**. Las alternativas actuales son:
- **Caras y corporativas** (Bizagi, Signavio, ARIS, Interact Process Manager): pensadas para grandes corporaciones, distantes de la realidad LATAM.
- **Genéricas** (Visio, draw.io, Lucidchart): dibujan procesos pero no los cuantifican.
- **Manuales** (Excel + Word + correo): no escalan, no son colaborativas, no son visuales.
### 1.2 Promesa de valor única (UVP)
> **Permitir a cualquier consultor o gerente de procesos navegar el resultado de la simulación de un proceso BPMN y entender visualmente, actividad por actividad, cómo se componen sus costos operativos y dónde están las oportunidades de mejora.**
### 1.3 Diferenciadores estratégicos
| Diferenciador | Cómo se materializa |
|---|---|
| **Cercanía LATAM** | Construido desde Paraguay, soporte en español LATAM, portugués BR e inglés, precio accesible |
| **Democratización del costeo operativo** | No solo medianas/grandes empresas: cualquier consultor independiente o pyme puede usarlo |
| **Visualización deslumbrante** | El mapa de calor sobre el BPMN y el reporte interactivo son el corazón del producto, no un anexo |
| **Educativa por diseño** | Glosario integrado, validador pedagógico, tutoriales in-app, plantillas comunitarias |
| **Forward-compatible con IA/RPA/Agentes** | El modelo de datos contempla nodos no-BPMN-estándar para representar bots RPA y agentes IA |
### 1.4 Audiencia objetivo
**MVP (Fase 0)**: el propio fundador como consultor de automatización de procesos.
**V1.0**: consultores independientes y boutique de procesos, gerentes de operaciones de pymes LATAM, equipos de Project/Process Office.
**V2+**: estudiantes y entusiastas de eficiencia operativa (tier educativo gratuito).
### 1.5 Lo que NO somos
- ❌ NO somos un BPMS (motor de ejecución de procesos).
- ❌ NO generamos código fuente para automatización (eso lo hace Claude Code u otro generador).
- ❌ NO competimos contra Camunda, Bonita, ni motores de orquestación.
- ❌ NO somos un editor BPMN general — usamos uno embebido (bpmn-js) pero nuestro valor está en el costeo y la simulación.
---
## 2. Glosario y Convenciones
| Término | Definición |
|---|---|
| **BPMN 2.0** | Business Process Model and Notation, estándar OMG para modelado de procesos |
| **Actividad** | Nodo BPMN tipo `Task` o `SubProcess` con trabajo asociado |
| **Costo directo** | Costo imputable a la ejecución de la actividad (recursos humanos, sistemas, insumos) |
| **Costo indirecto** | Costo asignado por overhead (estructura, espacio, indirectos prorrateados) |
| **Simulación** | Cálculo agregado del costo esperado de un proceso dadas las configuraciones de sus actividades |
| **Mapa de calor** | Visualización donde cada nodo BPMN se colorea según métrica de costo |
| **Tenant** | Organización/cliente lógicamente aislado en el SaaS (post-MVP) |
| **CRDT** | Conflict-free Replicated Data Type — estructura de datos para colaboración sin conflictos |
| **ABC** | Activity-Based Costing — costeo basado en actividades |
**Convenciones del documento**:
- 🔴 **MVP** = aplica a Fase 0 (esta noche)
- 🟡 **V1.0** = aplica al norte estratégico
- 🔵 **V2+** = futuro lejano
---
## 3. Norte Estratégico — Visión V1.0
### 3.1 Capacidades funcionales objetivo (V1.0)
```
┌──────────────────────────────────────────────────────────────────┐
│ PLATAFORMA V1.0 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 📐 DISEÑO BPMN │
│ ├─ Editor BPMN 2.0 completo (basado en bpmn-js extendido) │
│ ├─ Paleta extendida: elementos RPA + Agentes IA │
│ ├─ Validador pedagógico en tiempo real │
│ ├─ Glosario BPMN integrado │
│ └─ Importación/exportación BPMN 2.0 XML estándar │
│ │
│ 🤝 COLABORACIÓN │
│ ├─ Edición en tiempo real (hasta 5 usuarios concurrentes) │
│ ├─ CRDT-based, sin conflictos │
│ ├─ Presencia (cursores, selección, avatares) │
│ └─ Versionado lineal (snapshots manuales y automáticos) │
│ │
│ 🏢 ESTRUCTURA ORGANIZACIONAL │
│ ├─ Jerarquía configurable hasta 10 niveles │
│ ├─ Nombres de niveles configurables por tenant │
│ ├─ Procesos cross-area con gobernanza RACI │
│ └─ Process owners, SLAs entre áreas │
│ │
│ 💰 COSTEO Y SIMULACIÓN │
│ ├─ Costeo dual: ABC (Activity-Based) + por hora-recurso │
│ ├─ Tiempos como distribuciones de probabilidad │
│ ├─ Recursos: roles, personas, sistemas, equipamiento, insumos │
│ ├─ Riesgos cualitativos (matriz alto/medio/bajo) │
│ └─ Cálculo agregado de costos esperados │
│ │
│ 📊 VISUALIZACIÓN Y REPORTES │
│ ├─ Mapa de calor configurable (absoluto / relativo / desviación)│
│ ├─ Drill-down interactivo por actividad │
│ ├─ Reportes exportables (PDF, PNG, CSV, Excel) │
│ └─ Narrativa visual del costo end-to-end │
│ │
│ 🧠 INTELIGENCIA ARTIFICIAL │
│ └─ Detección automática de cuellos de botella e ineficiencias │
│ │
│ 🎓 EDUCACIÓN Y COMUNIDAD │
│ ├─ Tutoriales interactivos in-app │
│ ├─ Plantillas de procesos publicadas por la comunidad │
│ └─ Validador BPMN con explicaciones pedagógicas │
│ │
│ 🌐 PLATAFORMA │
│ ├─ SaaS multi-tenant │
│ ├─ PWA (instalable, offline-capable) │
│ ├─ i18n: es-LATAM, en, pt-BR │
│ ├─ Auth RBAC │
│ └─ Freemium + suscripción por usuario │
│ │
└──────────────────────────────────────────────────────────────────┘
```
### 3.2 Atributos de calidad objetivo (V1.0)
| Atributo | Objetivo |
|---|---|
| Disponibilidad | 99% (no SLA crítico al inicio) |
| Tiempo de carga inicial | < 3s en 3G |
| Concurrencia editorial | 5 usuarios por diagrama, latencia < 200ms |
| Tamaño máximo diagrama | 200 nodos sin degradación |
| Mantenibilidad | Stack monolítico modular, contratable en LATAM |
| Soberanía de datos | Self-hostable en cualquier VPS estándar |
---
## 4. Fase 0 — MVP "Noche Cero"
### 4.1 Hipótesis a validar
> Un consultor de procesos puede transformar un archivo `.bpmn` en un reporte visualmente deslumbrante de costos operativos en menos de 15 minutos, y usarlo al día siguiente en una reunión real con un cliente.
### 4.2 Alcance del MVP (estricto)
| # | Capacidad | Detalle |
|---|---|---|
| F1 | **Importar BPMN** | Drag & drop de archivo `.bpmn` 2.0 XML estándar |
| F2 | **Visualizar diagrama** | Canvas read-only con bpmn-js, navegable (zoom, pan) |
| F3 | **Editar atributos de actividad** | Panel lateral al seleccionar nodo: costo, tiempo, recursos asignados |
| F4 | **Definir recursos** | CRUD simple de recursos con costo/hora (roles, sistemas, insumos) |
| F5 | **Simular** | Cálculo agregado determinístico de costo total y por actividad |
| F6 | **Mapa de calor** | Coloreo de nodos sobre el canvas según costo relativo |
| F7 | **Reporte visual interactivo** | Dashboard con: total, ranking de actividades costosas, gráficos, drill-down |
| F8 | **Exportar reporte** | PDF + CSV |
| F9 | **Persistencia local** | IndexedDB: el trabajo del consultor se conserva entre sesiones del navegador |
### 4.3 Fuera de alcance del MVP
❌ Login/registro · ❌ Multi-tenant · ❌ Colaboración · ❌ Versionado · ❌ Edición del diagrama BPMN (solo lectura del XML importado) · ❌ Distribuciones de probabilidad (costos y tiempos fijos) · ❌ Riesgos · ❌ Gobernanza/RACI · ❌ Jerarquía organizacional · ❌ IA · ❌ Elementos RPA/Agente custom · ❌ Tutoriales · ❌ Marketplace · ❌ Backend · ❌ i18n (solo español inicialmente) · ❌ PWA installable (solo SPA web)
### 4.4 Criterios de éxito del MVP
✅ El consultor puede importar un BPMN real, configurarlo y obtener reporte en < 15 minutos
✅ El reporte tiene calidad visual presentable ante un cliente sin retoques
✅ La aplicación corre 100% en el navegador, sin backend
✅ El export PDF se ve profesional (logo, paleta, tipografía, layout)
✅ El mapa de calor es inmediatamente comprensible para alguien sin formación BPMN
### 4.5 Diseño del flujo de usuario del MVP
```
┌────────────────────────────────────────────────────────────────────┐
│ FLUJO MVP "NOCHE CERO" │
└────────────────────────────────────────────────────────────────────┘
[1. Landing]
│ "Importar mi proceso"
[2. Drop zone .bpmn] ─────────► IndexedDB
[3. Workspace de configuración]
├─ Canvas BPMN (read-only, navegable, bpmn-js)
├─ Panel lateral derecho:
│ ├─ Tab "Atributos de actividad" (al seleccionar nodo)
│ │ ├─ Costo directo
│ │ ├─ Tiempo de ejecución (minutos)
│ │ ├─ Recursos asignados (multi-select)
│ │ └─ Probabilidad de ocurrencia (para gateways)
│ ├─ Tab "Recursos" (CRUD global)
│ │ ├─ Nombre, tipo (rol/sistema/insumo/equipo), costo/hora
│ └─ Tab "Configuración global"
│ ├─ Moneda
│ ├─ Overhead % (costos indirectos)
│ └─ Nombre del proceso, cliente
│ Botón "Simular"
[4. Pantalla de Reporte]
├─ Header: nombre proceso, cliente, fecha, costo total
├─ Mapa de calor sobre el diagrama (gradiente verde→rojo)
├─ KPIs: costo total, costo/instancia, tiempo total, # actividades
├─ Tabla: top 10 actividades más costosas (interactivo)
├─ Gráfico de barras: costo por actividad
├─ Gráfico donut: composición costo directo/indirecto
├─ Gráfico de barras: costo por tipo de recurso
└─ Botones: "Exportar PDF" | "Exportar CSV" | "Volver a editar"
```
### 4.6 Decisiones de diseño visual (MVP)
| Aspecto | Decisión |
|---|---|
| Tipografía | Inter (UI) + JetBrains Mono (datos numéricos) |
| Paleta principal | Neutros oscuros sobre claros (light mode primero) |
| Paleta de heatmap | Verde (#10b981) → Amarillo (#f59e0b) → Rojo (#ef4444) |
| Componentes | shadcn/ui sobre Tailwind |
| Iconografía | Lucide |
| Densidad | Media-alta (consultor profesional, no consumidor) |
| Modo oscuro | Postergado para post-MVP |
### 4.7 Recomendación sobre el mapa de calor (decisión solicitada)
**Decisión arquitectónica**: el heatmap será **configurable con 3 modos**, default **costo relativo**.
| Modo | Cuándo se usa | Cómo colorea |
|---|---|---|
| **Costo relativo (%)** ← default | Análisis interno, identificar el 20% que cuesta el 80% | Cada nodo coloreado por su % sobre el total del proceso |
| **Costo absoluto** | Comparar entre procesos del mismo cliente | Escala global definida por el rango min-max del proceso |
| **Desviación vs benchmark** | Consultoría comparativa | Requiere benchmark del usuario; pospuesto a V1.0 si no hay tiempo esta noche |
**Justificación**: el modo relativo es el más útil para "comunicar dónde duele", que es el caso de uso del consultor. El absoluto se entrega gratis con el mismo cálculo. El de benchmark es valioso pero requiere base de datos comparativa que no tendremos esta noche.
---
## 5. Roadmap de Evolución
```
┌────────────┬──────────────────────────────────────────────────────┐
│ FASE │ CAPACIDADES │
├────────────┼──────────────────────────────────────────────────────┤
│ Fase 0 │ MVP client-side: importar, configurar, simular, │
│ (1 noche) │ reportear, exportar PDF/CSV │
├────────────┼──────────────────────────────────────────────────────┤
│ Fase 1 │ + Backend mínimo + Auth + Persistencia en la nube │
│ (1-2 sem) │ + i18n (es/en/pt-BR) + PWA installable │
├────────────┼──────────────────────────────────────────────────────┤
│ Fase 2 │ + Editor BPMN (no solo lectura) + Validador │
│ (1 mes) │ + Glosario integrado + Recursos avanzados │
├────────────┼──────────────────────────────────────────────────────┤
│ Fase 3 │ + Estructura organizacional + Multi-tenant │
│ (1 mes) │ + RBAC + Versionado lineal │
├────────────┼──────────────────────────────────────────────────────┤
│ Fase 4 │ + Colaboración en tiempo real (Yjs/CRDT) │
│ (1-2 mes) │ + Presencia + Comentarios │
├────────────┼──────────────────────────────────────────────────────┤
│ Fase 5 │ + Distribuciones probabilísticas + Riesgos │
│ (1 mes) │ + Costeo ABC completo + Gobernanza RACI │
├────────────┼──────────────────────────────────────────────────────┤
│ Fase 6 │ + IA: detección de cuellos de botella │
│ (1-2 mes) │ + Elementos RPA/Agente IA en paleta │
├────────────┼──────────────────────────────────────────────────────┤
│ Fase 7 │ + Marketplace comunitario de plantillas │
│ (continuo) │ + Tutoriales interactivos + Modo educativo │
└────────────┴──────────────────────────────────────────────────────┘
```
---
## 6. Arquitectura Técnica
### 6.1 Vista de alto nivel — MVP (Fase 0)
```
┌─────────────────────────────────────────────────────────────┐
│ NAVEGADOR DEL USUARIO │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ React SPA (Vite + TypeScript) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ Router │ │ State │ │ UI Layer │ │ │
│ │ │ (TanStack│ │ (Zustand)│ │ (shadcn + │ │ │
│ │ │ Router)│ │ │ │ Tailwind) │ │ │
│ │ └──────────┘ └──────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌──────────────────────┐ │ │
│ │ │ bpmn-js │ │ Simulation Engine │ │ │
│ │ │ (canvas viewer) │ │ (TypeScript puro) │ │ │
│ │ └─────────────────┘ └──────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌──────────────────────┐ │ │
│ │ │ D3 + ECharts │ │ Export Layer │ │ │
│ │ │ (heatmap + viz) │ │ (jsPDF + html2canvas)│ │ │
│ │ └─────────────────┘ └──────────────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ IndexedDB (via Dexie.js) │ │
│ │ - processes (BPMN XML + metadata) │ │
│ │ - resources │ │
│ │ - activities (costos, tiempos, recursos) │ │
│ │ - simulations (resultados snapshots) │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Deployable a: Cloudflare Pages / Vercel
(estático, gratis, global)
```
### 6.2 Vista de alto nivel — V1.0
```
┌──────────────────┐ ┌──────────────────────────────┐
│ Cliente PWA │ HTTPS │ API Gateway (Caddy) │
│ (React + Vite) ├────────►│ │
└────────┬─────────┘ └──────────────┬───────────────┘
│ │
│ WSS (Yjs) │ REST + WebSocket
│ ▼
│ ┌───────────────────────────────┐
│ │ Backend (NestJS Monolito │
│ │ Modular en Node.js) │
│ │ │
│ │ ┌─────────┬─────────┐ │
│ │ │ Auth │ Process │ │
│ │ │ Module │ Module │ │
│ │ ├─────────┼─────────┤ │
│ │ │ Org │ Simul. │ │
│ │ │ Module │ Module │ │
│ │ ├─────────┼─────────┤ │
│ │ │ Collab │ AI │ │
│ │ │ Module │ Module │ │
│ │ └─────────┴─────────┘ │
│ └────────┬──────────────────────┘
│ │
└──Yjs WebSocket────────────┤
┌──────────────────────┼──────────────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ PostgreSQL │ │ Redis │ │ MinIO │
│ (datos) │ │ (cache, │ │ (archivos, │
│ │ │ pubsub, │ │ exports) │
│ │ │ sessions) │ │ │
└────────────┘ └────────────┘ └────────────┘
Todo desplegable en una VPS (Hostinger / Hetzner)
vía Docker Compose
```
### 6.3 Stack tecnológico
#### 6.3.1 Stack MVP (Fase 0)
| Capa | Tecnología | Versión | Justificación |
|---|---|---|---|
| Lenguaje | TypeScript | 5.x | Type safety, mantenibilidad |
| Framework UI | React | 18.x | Ecosistema, Claude Code domina |
| Build tool | Vite | 5.x | Velocidad, simplicidad |
| Routing | TanStack Router | 1.x | Type-safe, moderno |
| Estado | Zustand | 4.x | Simple, sin boilerplate, escala |
| Canvas BPMN | bpmn-js | 17.x | Estándar de facto, MIT, Camunda |
| UI components | shadcn/ui | latest | Copy-paste, no es dependencia rígida |
| Estilos | Tailwind CSS | 3.x | Productividad, mantenibilidad |
| Iconos | Lucide React | latest | Consistencia visual |
| Gráficos | Apache ECharts | 5.x | Calidad visual superior para reportes |
| Heatmap canvas | D3.js (selecciones) | 7.x | Coloreo de nodos bpmn-js |
| Persistencia | IndexedDB vía Dexie | 4.x | API ergonómica sobre IndexedDB |
| Export PDF | jsPDF + html2canvas | latest | Render WYSIWYG del reporte |
| Export CSV | papaparse | 5.x | Robusto, manejo de edge cases |
| Validación schemas | Zod | 3.x | Schemas type-safe |
| Testing | Vitest + Testing Library | latest | Suficiente para MVP |
| Deploy | Cloudflare Pages | — | Gratis, global CDN, SSL incluido |
#### 6.3.2 Stack evolutivo (V1.0)
| Capa | Tecnología | Justificación |
|---|---|---|
| Backend framework | **NestJS** | Monolito modular natural, DI, contratable |
| Runtime | Node.js LTS | Mismo lenguaje que frontend |
| ORM | Prisma | Type-safe, migraciones, excelente DX |
| Base de datos | PostgreSQL 16 | Robustez relacional + JSONB para BPMN |
| Cache/PubSub | Redis 7 | Sessions, Yjs awareness |
| Object storage | MinIO (self-hosted S3) | Exports, attachments, soberanía |
| Realtime | Yjs + y-websocket | CRDT estándar para colaboración |
| Auth | Better-Auth | Self-hosted, moderno, RBAC nativo |
| API Gateway | Caddy | TLS automático, reverse proxy simple |
| Containerización | Docker + Docker Compose | Suficiente para VPS única |
| Observabilidad | OpenTelemetry → Grafana stack | Cuando llegue el momento |
| i18n | i18next + react-i18next | Estándar de facto |
| E2E testing | Playwright | Estándar moderno |
### 6.4 Arquitectura de carpetas (MVP)
```
process-cost-platform/
├── README.md
├── ARCHITECTURE.md ← este documento
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.ts
├── index.html
├── public/
│ └── sample-processes/ ← BPMNs de ejemplo para demos
├── src/
│ ├── main.tsx ← entry point
│ ├── App.tsx ← shell de la app
│ ├── router.tsx ← TanStack Router
│ │
│ ├── domain/ ← modelo puro, sin UI
│ │ ├── types.ts ← Process, Activity, Resource, Simulation
│ │ ├── simulation.ts ← motor de cálculo (TypeScript puro)
│ │ ├── bpmn-parser.ts ← extracción de nodos de XML BPMN
│ │ └── schemas.ts ← validación Zod
│ │
│ ├── store/ ← Zustand stores
│ │ ├── process-store.ts
│ │ ├── resources-store.ts
│ │ └── simulation-store.ts
│ │
│ ├── persistence/ ← IndexedDB / Dexie
│ │ ├── db.ts ← schema Dexie
│ │ └── repositories.ts ← funciones CRUD
│ │
│ ├── features/ ← organización por feature
│ │ ├── import/
│ │ │ ├── ImportDropzone.tsx
│ │ │ └── useImportBpmn.ts
│ │ ├── workspace/
│ │ │ ├── WorkspacePage.tsx
│ │ │ ├── BpmnCanvas.tsx ← integra bpmn-js
│ │ │ ├── ActivityPanel.tsx ← edición de atributos
│ │ │ ├── ResourcesPanel.tsx
│ │ │ └── GlobalSettingsPanel.tsx
│ │ ├── simulation/
│ │ │ └── useSimulate.ts ← hook que orquesta el cálculo
│ │ └── report/
│ │ ├── ReportPage.tsx
│ │ ├── HeatmapCanvas.tsx ← bpmn-js + D3 overlay
│ │ ├── KpiHeader.tsx
│ │ ├── TopActivitiesTable.tsx
│ │ ├── CostByActivityChart.tsx
│ │ ├── CostCompositionChart.tsx
│ │ ├── CostByResourceChart.tsx
│ │ └── ExportControls.tsx
│ │
│ ├── components/ ← componentes compartidos (shadcn + custom)
│ │ ├── ui/ ← shadcn copy-pastes
│ │ └── ...
│ │
│ ├── lib/ ← utilidades transversales
│ │ ├── format.ts ← formateo de moneda, números
│ │ ├── colors.ts ← lógica de heatmap
│ │ ├── pdf-export.ts ← jsPDF + html2canvas
│ │ └── csv-export.ts ← papaparse
│ │
│ └── styles/
│ └── globals.css
└── tests/
├── domain/
│ └── simulation.test.ts ← tests del motor
└── e2e/ ← post-MVP
```
### 6.5 Modelo conceptual del motor de simulación (MVP)
El motor de simulación del MVP es **determinístico y agregado**. NO es discrete event simulation. Su contrato:
```typescript
interface SimulationInput {
processXml: string;
activities: Map<ActivityId, ActivityConfig>;
resources: Resource[];
globalSettings: {
currency: string;
overheadPercentage: number; // % sobre costos directos
};
}
interface ActivityConfig {
activityId: string;
directCostFixed: number; // costo fijo por ejecución
executionTimeMinutes: number; // tiempo estimado
assignedResources: Array<{
resourceId: string;
utilizationPercent: number; // % del tiempo de la actividad
}>;
probabilityOfExecution: number; // 0..1, default 1
// Para gateways: probabilidad por flujo saliente
}
interface SimulationResult {
totalCost: number;
totalDirectCost: number;
totalIndirectCost: number;
totalTimeMinutes: number;
perActivity: Array<{
activityId: string;
activityName: string;
expectedDirectCost: number;
expectedIndirectCost: number;
expectedTotalCost: number;
percentOfTotal: number;
expectedExecutions: number; // por la prob. acumulada del camino
resourceCostBreakdown: Array<{ resourceId: string; cost: number }>;
}>;
perResource: Array<{
resourceId: string;
totalCost: number;
totalMinutesUsed: number;
}>;
warnings: string[]; // ej. "el proceso tiene un loop sin probabilidad definida"
}
```
**Algoritmo (alto nivel)**:
1. Parsear el BPMN XML → grafo dirigido de nodos y flujos.
2. Calcular probabilidad acumulada de ejecución para cada actividad mediante recorrido desde Start Events, propagando probabilidades por gateways.
3. Detectar y advertir loops (en MVP los tratamos como ejecución única; manejo riguroso en V1.0).
4. Para cada actividad: `costoEsperado = (costoDirectoFijo + Σ(recursos.costo/hora × utilización × tiempo/60)) × probabilidadAcumulada`.
5. Costo indirecto = `costoDirectoTotal × overheadPercentage`.
6. Agregar totales y rankings.
### 6.6 Integración bpmn-js + heatmap
`bpmn-js` expone:
- Un `viewer` (read-only) ideal para MVP.
- API para añadir overlays/colores a elementos por ID.
- Eventos de click sobre elementos del diagrama.
El heatmap se implementa así:
1. Cargar BPMN con `Viewer.importXML(xml)`.
2. Tras la simulación, iterar resultados y para cada actividad:
- Calcular color según modo (relativo/absoluto).
- Aplicar fill al elemento SVG via `canvas.addMarker(id, className)` o manipulación directa del SVG hijo del nodo.
3. Registrar handler `element.click` para drill-down.
4. Mostrar tooltip con detalle al hover.
### 6.7 Deployment del MVP
**Esta noche**:
1. `npm run build` → genera `dist/` estático.
2. Subir a Cloudflare Pages (drag & drop o GitHub integration).
3. Dominio: `tu-subdominio.pages.dev` o dominio propio.
**Costo**: $0.
---
## 7. Modelo de Dominio
### 7.1 Entidades núcleo (MVP)
**Distinción fundamental de capas:**
- `ProcessGraph` (en memoria, reconstruido del XML cada vez): TODOS los nodos del diagrama incluidos gateways, eventos, flujos. Vive solo en el parser/motor, no se persiste.
- `Activity` (persistido en IndexedDB): solo `task` y `subprocess` — nodos con trabajo costeable.
- `GatewayConfig` (persistido en IndexedDB): probabilidades por flujo saliente de gateways divergentes (XOR/OR). Los gateways convergentes (merge) no tienen config — el motor suma probabilidades entrantes.
- El heatmap **solo colorea nodos con Activity**. Los gateways conservan estilo BPMN neutro.
```
┌─────────────────────────────────────────────────────────────┐
│ Process │
├─────────────────────────────────────────────────────────────┤
│ + id: UUID │
│ + name: string │
│ + clientName: string │
│ + bpmnXml: string (raw XML) │
│ + currency: string (ISO 4217) │
│ + overheadPercentage: number (0..1) │
│ + createdAt, updatedAt │
└─────────────────────────────────────────────────────────────┘
│ 1 │ 1
│ │
│ N │ N
┌─────────────────────────┐ ┌──────────────────────────────┐
│ Activity │ │ GatewayConfig │
├─────────────────────────┤ ├──────────────────────────────┤
│ + id: UUID │ │ + id: UUID │
│ + processId: UUID (FK) │ │ + processId: UUID (FK) │
│ + bpmnElementId: string │ │ + bpmnElementId: string │
│ (FK conceptual al │ │ (id del gateway en XML) │
│ nodo en el XML) │ │ + gatewayType: │
│ + name: string │ │ 'exclusive'|'inclusive' │
│ + type: │ │ |'parallel' │
│ 'task'|'subprocess' │ │ + branches: Array<{ │
│ + directCostFixed: │ │ flowId: string, │
│ number │ │ probability: number │
│ + executionTimeMinutes: │ │ }> │
│ number │ │ (paralelo: todas 1.0, │
└─────────────────────────┘ │ exclusivo: suma = 1.0) │
│ N └──────────────────────────────┘
│ M
┌─────────────────────────────────────────────────────────────┐
│ ActivityResourceAssignment │
├─────────────────────────────────────────────────────────────┤
│ + activityId: UUID (FK) │
│ + resourceId: UUID (FK) │
│ + utilizationPercent: number (0..1) │
└─────────────────────────────────────────────────────────────┘
│ N
│ 1
┌─────────────────────────────────────────────────────────────┐
│ Resource │
├─────────────────────────────────────────────────────────────┤
│ + id: UUID │
│ + name: string │
│ + type: 'role' | 'person' | 'system' | 'equipment' │
│ | 'supply' │
│ + costPerHour: number │
│ + currency: string │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Simulation │
├─────────────────────────────────────────────────────────────┤
│ + id: UUID │
│ + processId: UUID (FK) │
│ + executedAt: timestamp │
│ + resultJson: SimulationResult (snapshot) │
└─────────────────────────────────────────────────────────────┘
```
### 7.2 Modelo extendido (V1.0)
Añade: `Tenant`, `User`, `Role`, `OrganizationUnit` (jerárquica recursiva), `ProcessOwnership` (RACI), `Risk`, `RiskProcessLink`, `ProcessVersion`, `Comment`, `Notification`, `Template`.
---
## 8. Decisiones Arquitectónicas (ADRs)
### ADR-001 — MVP client-side sin backend
- **Contexto**: tiempo de entrega = una noche, presupuesto cero.
- **Decisión**: el MVP corre 100% en navegador con IndexedDB.
- **Consecuencias**: ✅ Cero costo, cero infraestructura. ❌ Sin colaboración, sin sync entre dispositivos.
- **Cuándo revisar**: al inicio de Fase 1.
### ADR-002 — bpmn-js como motor de canvas
- **Contexto**: existen 3 opciones — bpmn-js, librería custom, render SVG manual.
- **Decisión**: usar bpmn-js (Camunda, MIT).
- **Consecuencias**: ✅ Estándar de la industria, mantenido, soporta BPMN 2.0 XML estándar end-to-end. ❌ Atado a su modelo; extensiones de paleta requerirán plugins propios.
- **Alternativas descartadas**: librería propia (reinventar la rueda, costo prohibitivo).
### ADR-003 — Persistencia con BPMN XML estándar como fuente de verdad
- **Contexto**: hay que decidir si guardamos BPMN XML, JSON propio, o ambos.
- **Decisión**: BPMN XML 2.0 estándar como fuente de verdad. Los costos/tiempos/recursos se guardan en estructuras paralelas vinculadas por `bpmnElementId`.
- **Consecuencias**: ✅ Interoperabilidad con cualquier herramienta BPMN. ❌ Costos no viajan dentro del XML (no es estándar BPMN); requiere export combinado en el futuro.
### ADR-004 — React + Vite + TypeScript
- **Decisión**: React 18 + Vite + TS estricto.
- **Justificación**: ecosistema más grande, Claude Code óptimo en React, contratable en LATAM.
- **Alternativas descartadas**: Vue (menos ecosistema BPMN), Svelte (menor masa crítica), Next.js (SSR innecesario para MVP).
### ADR-005 — Zustand para estado global
- **Decisión**: Zustand sobre Redux/Jotai/Context.
- **Justificación**: simplicidad, sin boilerplate, suficiente para alcance MVP y V1.0.
### ADR-006 — NestJS para backend (V1.0)
- **Decisión**: NestJS como framework backend.
- **Justificación**: modularidad por design (encaja con monolito modular), DI nativo, tipado fuerte, ecosistema empresarial.
- **Alternativas descartadas**: Fastify puro (menos estructura), Express (legacy), Python/FastAPI (cambio de lenguaje innecesario).
### ADR-007 — PostgreSQL + Prisma (V1.0)
- **Decisión**: Postgres como BD principal, Prisma como ORM.
- **Justificación**: relaciones jerárquicas + JSONB para BPMN + robustez probada + tipado e2e con Prisma.
### ADR-008 — Yjs + y-websocket para colaboración (V1.0)
- **Decisión**: CRDTs vía Yjs.
- **Justificación**: estándar en editores colaborativos modernos, sin necesidad de transformación operacional, escala bien.
### ADR-009 — Monolito modular antes que microservicios
- **Decisión**: NestJS monolito con módulos por dominio.
- **Justificación**: equipo pequeño, complejidad innecesaria de microservicios, posibilidad de extraer servicios después si hace falta.
### ADR-010 — Heatmap configurable con default "costo relativo"
- **Decisión**: 3 modos, default relativo.
- **Justificación**: el modo relativo comunica mejor "dónde duele" — el caso de uso primario del consultor.
### ADR-011 — Deploy en Cloudflare Pages para MVP, VPS para V1.0
- **Decisión**: Cloudflare Pages mientras sea estático; migración a VPS al introducir backend.
- **Justificación**: $0 al inicio, latencia global, sin operación. Migrar cuando haya backend.
---
## 9. Atributos de Calidad
### 9.1 Performance
| Métrica | MVP | V1.0 |
|---|---|---|
| Tiempo carga inicial | < 2s | < 3s en 3G |
| Tamaño bundle JS inicial | < 500KB gzip | < 700KB gzip |
| Cálculo de simulación (50 nodos) | < 100ms | < 500ms |
| Render mapa de calor | < 200ms | < 200ms |
| Export PDF | < 5s | < 5s |
### 9.2 Mantenibilidad
- **TypeScript strict** activado.
- **ESLint + Prettier** configurados.
- **Convención de carpetas por feature**, no por tipo de archivo.
- **Dominio puro separado** de UI (en `/src/domain`) — testeable sin React.
- **Sin lógica de negocio en componentes** — siempre en hooks o domain.
### 9.3 Seguridad (V1.0)
- TLS obligatorio (Cloudflare en MVP, Caddy en VPS).
- Auth con tokens cortos + refresh.
- RBAC: roles `owner`, `admin`, `editor`, `viewer`.
- Sanitización del BPMN XML antes de parsear (evitar XXE).
- CSP estricto en producción.
- Backups cifrados (V1.0).
### 9.4 Accesibilidad
- WCAG 2.1 AA como objetivo V1.0.
- Contraste mínimo 4.5:1 en texto.
- Navegación por teclado en el flujo principal.
- Labels en todos los inputs.
---
## 10. Riesgos y Mitigaciones
| # | Riesgo | Probabilidad | Impacto | Mitigación |
|---|---|---|---|---|
| R1 | El parser BPMN no maneja todos los XML que importe el consultor | Alta | Alto en MVP | Documentar elementos soportados; mostrar warnings claros; testear con BPMNs reales esta noche |
| R2 | El cálculo agregado es engañoso ante loops complejos | Media | Medio | Detectar loops y advertir; en V1.0 implementar Monte Carlo |
| R3 | El reporte exportado a PDF se ve mal por diferencias de render | Media | Alto en MVP | Diseñar el reporte pensando en print desde el día uno; testear export inmediatamente |
| R4 | El cliente del consultor cuestiona la metodología de costeo | Alta | Medio | Documentar fórmula visible en el reporte; permitir override de overhead |
| R5 | IndexedDB se llena o se pierde (modo incógnito, limpieza) | Media | Medio | Botón "Exportar workspace completo" como respaldo manual; en Fase 1 migrar a backend |
| R6 | Performance degrada con diagramas > 100 nodos | Baja en MVP | Bajo | MVP solo soporta hasta 50 nodos por requisito |
| R7 | Dependencia de bpmn-js a futuro (lock-in) | Baja | Bajo | bpmn-js es MIT y mantenido por Camunda; standard BPMN XML protege salida |
| R8 | El usuario espera edición y se frustra con read-only | Alta | Alto | Comunicar claramente que es importación + costeo; Fase 2 trae edición |
| R9 | Caída de motivación / scope creep | Alta | Crítico | Este documento existe para anclar el alcance. Defenderlo. |
---
## 11. Anexos
### 11.1 Comandos de arranque para Claude Code (MVP)
```bash
# Inicialización
npm create vite@latest process-cost-platform -- --template react-ts
cd process-cost-platform
npm install
# Core libs
npm install bpmn-js zustand dexie @tanstack/react-router zod
npm install d3 echarts echarts-for-react
npm install jspdf html2canvas papaparse
npm install lucide-react
# Tailwind + shadcn
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
npx shadcn@latest init
# Dev tooling
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
npm install -D eslint prettier
```
### 11.2 BPMN XML de prueba sugerido
Para testing inicial, usar el BPMN público de ejemplo de bpmn.io: https://github.com/bpmn-io/bpmn-js-examples
### 11.3 Recursos de referencia
- **bpmn-js docs**: https://bpmn.io/toolkit/bpmn-js/
- **BPMN 2.0 spec**: https://www.omg.org/spec/BPMN/2.0/
- **shadcn/ui**: https://ui.shadcn.com/
- **TanStack Router**: https://tanstack.com/router
- **Zustand**: https://zustand-demo.pmnd.rs/
- **Dexie.js**: https://dexie.org/
### 11.4 Definition of Done — MVP "Noche Cero"
- [ ] El usuario puede importar un archivo `.bpmn` por drag & drop
- [ ] El diagrama se renderiza correctamente en el canvas
- [ ] El usuario puede seleccionar cualquier actividad y editar sus atributos
- [ ] El usuario puede crear, editar y eliminar recursos
- [ ] El usuario puede configurar moneda y overhead global
- [ ] Al hacer click en "Simular", aparece un reporte
- [ ] El reporte muestra mapa de calor sobre el diagrama
- [ ] El reporte muestra KPIs, ranking, gráficos
- [ ] El usuario puede exportar el reporte a PDF y CSV
- [ ] El estado persiste al recargar la página (IndexedDB)
- [ ] El PDF exportado es presentable a un cliente real
- [ ] Deploy público accesible (Cloudflare Pages)
---
> **Nota final del arquitecto**:
>
> Este documento es un contrato contigo mismo. El mayor riesgo del proyecto no es técnico, es la dispersión. Cada vez que sientas la tentación de agregar algo al MVP "porque sería genial", vuelve a la sección 4.3 y recuerda lo que dejaste fuera **por diseño**.
>
> El MVP no demuestra todas las capacidades de la plataforma. Demuestra **una sola cosa**: que un consultor puede transformar un BPMN en un reporte de costos visualmente deslumbrante en minutos. Si esta noche logras eso, ganaste.

173
CLAUDE.md Normal file
View File

@@ -0,0 +1,173 @@
# CLAUDE.md — Contexto del Proyecto
> Este archivo es leído automáticamente por Claude Code en cada sesión.
> Define el "norte" del proyecto y las reglas no negociables.
---
## Identidad del Proyecto
**Nombre de trabajo**: Process Cost Platform (nombre comercial por definir)
**Una frase**: Plataforma web para que consultores de procesos transformen un archivo BPMN 2.0 en un reporte visual deslumbrante de costos operativos en minutos.
**Mantra**: *Los procesos de negocio son activos estratégicos de las empresas y un diferencial competitivo.*
---
## Estado Actual
- **Fase**: 0 — MVP "Noche Cero"
- **Documento maestro**: `ARCHITECTURE.md` (raíz del repo)
- **Fecha objetivo MVP**: una noche
- **Deploy objetivo**: Cloudflare Pages (estático, gratis)
---
## Reglas No Negociables del MVP
### ✅ Lo que SÍ hacemos esta noche
1. Importar archivo `.bpmn` por drag & drop
2. Renderizar el diagrama en read-only con bpmn-js
3. Editar atributos de actividades (costo, tiempo, recursos asignados)
4. CRUD de recursos (rol/persona/sistema/equipo/insumo + costo/hora)
5. Configuración global (moneda, overhead %, nombre proceso, cliente)
6. Motor de simulación determinístico agregado
7. Reporte visual con mapa de calor sobre el diagrama
8. Gráficos: KPIs, top actividades, costo por actividad, composición, costo por recurso
9. Export a PDF (presentable a cliente) y CSV
10. Persistencia en IndexedDB (Dexie)
### ❌ Lo que NO hacemos esta noche
Login · Multi-tenant · Backend · Colaboración · Edición del BPMN · Versionado · Distribuciones probabilísticas · Riesgos · Gobernanza/RACI · Jerarquía organizacional · IA · Elementos RPA/Agente custom · Tutoriales · Marketplace · i18n · PWA installable · Modo oscuro
**Si dudás si una feature entra: NO ENTRA.** Defendé el alcance.
---
## Stack Obligatorio (MVP)
| Capa | Tecnología |
|---|---|
| Lenguaje | TypeScript estricto |
| Framework | React 18 + Vite |
| Routing | TanStack Router |
| Estado | Zustand |
| Canvas BPMN | bpmn-js (Viewer, no Modeler) |
| UI | shadcn/ui sobre Tailwind CSS |
| Iconos | Lucide React |
| Gráficos | Apache ECharts (vía echarts-for-react) |
| Persistencia | IndexedDB vía Dexie |
| Validación | Zod |
| Export PDF | jsPDF + html2canvas |
| Export CSV | papaparse |
| Testing | Vitest |
| Deploy | Cloudflare Pages |
**No introduzcas librerías fuera de esta lista sin justificación explícita.**
---
## Arquitectura de Carpetas (obligatoria)
```
src/
├── main.tsx
├── App.tsx
├── router.tsx
├── domain/ ← lógica de negocio pura, sin React, testeable
│ ├── types.ts
│ ├── simulation.ts ← motor de simulación
│ ├── bpmn-parser.ts
│ └── schemas.ts ← Zod schemas
├── store/ ← Zustand stores
├── persistence/ ← Dexie / IndexedDB
├── features/ ← organizado por feature, no por tipo
│ ├── import/
│ ├── workspace/
│ ├── simulation/
│ └── report/
├── components/ui/ ← shadcn copy-pastes
├── lib/ ← utilidades transversales (format, colors, pdf, csv)
└── styles/
```
**Reglas de dependencia**:
- `domain/` NO importa de React, ni de `store/`, ni de `features/`. Es puro.
- `features/` puede importar de `domain/`, `store/`, `persistence/`, `lib/`, `components/`.
- `store/` puede importar de `domain/` y `persistence/`. Nada más.
---
## Principios de Código
1. **TypeScript strict** activado. Nada de `any` salvo en bordes con librerías sin tipos.
2. **Dominio puro**: el motor de simulación es una función pura testeable sin DOM.
3. **Componentes tontos, hooks inteligentes**: lógica en custom hooks, JSX limpio.
4. **Sin lógica de negocio en componentes**.
5. **Convenciones de naming**:
- Componentes: `PascalCase.tsx`
- Hooks: `useCamelCase.ts`
- Utilidades: `kebab-case.ts`
- Tipos: `PascalCase`, interfaces con prefijo `I` PROHIBIDO
6. **Comentarios en español**, código (variables, funciones) en inglés.
7. **Errores con mensajes accionables** para el usuario, nunca traces crudos en UI.
---
## Diferencial Visual (CRÍTICO)
El 60% del valor del MVP está en **el reporte final**. Si el reporte se ve mediocre, el MVP fracasa aunque el resto funcione. Reglas:
- Tipografía: **Inter** para UI, **JetBrains Mono** para números/datos
- Paleta heatmap: verde `#10b981` → amarillo `#f59e0b` → rojo `#ef4444`
- Cifras de dinero siempre formateadas con separadores y símbolo de moneda
- El export PDF debe verse igual o mejor que la pantalla
- Cada gráfico tiene título, leyenda clara y formato de moneda apropiado
- Densidad de información media-alta (audiencia: consultor profesional)
---
## Definition of Done — Cada Feature
Antes de marcar una tarea como terminada:
- [ ] Funciona en el happy path
- [ ] Maneja el caso de datos vacíos sin crashear
- [ ] Persiste en IndexedDB cuando corresponde
- [ ] Tiene estilo consistente con el resto de la app
- [ ] Si toca dominio: tiene al menos un test en Vitest
---
## Comandos del Proyecto
```bash
npm run dev # desarrollo
npm run build # producción
npm run test # tests con Vitest
npm run lint # eslint
npm run preview # previsualizar build
```
---
## Cómo Trabajar Conmigo (el Usuario)
- Cuando termines una tarea grande, **resumí qué cambió y qué falta**.
- Si una decisión no está clara, **proponé 2-3 opciones** con pros/contras antes de codear.
- Si encontrás que algo del documento de arquitectura está mal o falta, **avisame antes de improvisar**.
- No agregues features no pedidas. Si algo se te ocurre, anotalo en `TODO.md`.
- Cuando completes algo, **mostrame los archivos creados/modificados**, no los pegues completos en el chat salvo que te lo pida.
---
## Contexto del Fundador
- Consultor de automatización de procesos basado en Paraguay
- Construye esto como herramienta consultiva propia + para vender a colegas LATAM
- Usa Claude Code Pro como único "equipo de desarrollo"
- Lo va a presentar a clientes reales en días, no meses
- Valora: claridad, robustez, pulido visual, mantenibilidad

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
# Process Cost Platform — MVP Fase 0
Plataforma web que transforma un archivo BPMN 2.0 en un reporte visual
de costos operativos, listo para presentar a clientes en minutos.
100% en el navegador — sin backend, sin registro, datos guardados localmente.
## Cómo usarlo
1. **Importá** tu archivo `.bpmn` arrastrándolo a la pantalla principal,
o cargá uno de los tres procesos de ejemplo incluidos.
2. **Configurá** los costos fijos y tiempos de cada actividad en el workspace.
Ajustá las probabilidades de los gateways XOR si las hay.
3. **Simulá** para generar el análisis agregado de costos.
4. **Exportá** el reporte en PDF (presentación para el cliente) o CSV (datos).
## Stack
| Capa | Tecnología |
|---|---|
| Framework | React 19 + Vite 8 |
| Routing | TanStack Router |
| Estado | Zustand 5 |
| Canvas BPMN | bpmn-js 18 (Viewer) |
| UI | shadcn/ui + Tailwind CSS |
| Gráficos | Apache ECharts |
| Persistencia | IndexedDB vía Dexie 4 |
| Export PDF | jsPDF + jspdf-autotable |
| Export CSV | papaparse |
| Testing | Vitest + Playwright |
| Deploy | Cloudflare Pages |
## Scripts
```bash
npm run dev # Servidor de desarrollo (localhost:5173)
npm run build # Build de producción en dist/
npm run preview # Preview del build en localhost:4173
npm run test # Tests unitarios con Vitest
npx playwright test # Tests E2E con Playwright
```
## Estado
**MVP Fase 0** — funcionalidad completa para uso consultivo básico.
Las features adicionales (multi-tenant, IA, versionado, etc.)
están documentadas en `TODO.md` para la Fase 1.

33
TODO.md Normal file
View File

@@ -0,0 +1,33 @@
# TODO — Process Cost Platform · Fase 1
> Features descartadas del MVP (scope defendido) para no arriesgar la fecha.
> Ordenadas por impacto estimado.
## Alta prioridad
- [ ] **Múltiples simulaciones**: historial de simulaciones por proceso con comparación de escenarios
- [ ] **Recursos con costo por hora**: link actividad ↔ recurso (rol, persona, sistema) con cálculo automático
- [ ] **Edición del nombre del proceso y cliente** desde el workspace (sin ir a configuración global)
- [ ] **Gateway OR inclusivo nativo**: actualmente se simula como XOR; implementar propagación OR real
- [ ] **Distribuciones de probabilidad**: gamma/triangular por actividad en lugar de valores fijos
## Media prioridad
- [ ] **Export a Excel** (.xlsx) con múltiples hojas (resumen + por actividad + metodología)
- [ ] **Modo oscuro** (Dark mode)
- [ ] **i18n**: inglés + español configurable
- [ ] **PWA installable**: manifest + service worker para uso offline
- [ ] **Zoom/pan del heatmap** desde el reporte (ahora es solo visualización estática)
- [ ] **Detalle de recursos por actividad** en el PDF (tabla de costos por recurso asignado)
- [ ] **Importación por URL**: cargar BPMN desde enlace directo (GitHub, Confluence, etc.)
## Baja prioridad / Fase 2
- [ ] **Login y multi-tenant**: persistencia en la nube, colaboración
- [ ] **Versionado de procesos**: historial de cambios con diff visual
- [ ] **Gobernanza/RACI**: asignación de responsables por actividad
- [ ] **Jerarquía organizacional**: integración con orgchart para asignar costos por área
- [ ] **IA / asistente**: sugerencias de optimización, benchmarking automático
- [ ] **Marketplace de procesos**: biblioteca de BPMNs precostos por industria
- [ ] **Elementos RPA y agentes**: soporte para costos de bots y workflows automatizados
- [ ] **Tutoriales interactivos**: onboarding guiado para usuarios nuevos

26
index.html Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Process Cost Platform — Cuantificá tus procesos</title>
<meta name="description" content="Importá un archivo BPMN 2.0 y obtené un reporte visual de costos operativos listo para presentar a tu cliente en minutos. 100% en tu navegador, sin registro." />
<!-- Open Graph / Social sharing -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Process Cost Platform — Cuantificá tus procesos" />
<meta property="og:description" content="Transformá un BPMN 2.0 en un reporte de costos operativos en minutos. Heatmap, gráficos, export PDF y CSV." />
<meta property="og:locale" content="es_PY" />
<!-- Twitter / WhatsApp fallback -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Process Cost Platform" />
<meta name="twitter:description" content="Cuantificá el costo operativo de tus procesos BPMN en minutos." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8584
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

81
package.json Normal file
View File

@@ -0,0 +1,81 @@
{
"name": "process-cost-platform",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-router": "^1.169.2",
"@tanstack/router-devtools": "^1.166.13",
"@types/html2canvas": "^0.5.35",
"bpmn-js": "^18.16.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3": "^7.9.0",
"dexie": "^4.4.2",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^1.14.0",
"papaparse": "^5.5.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"tailwind-merge": "^3.6.0",
"uuid": "^14.0.0",
"zod": "^4.4.3",
"zustand": "^5.0.13"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/d3": "^7.4.3",
"@types/node": "^24.12.3",
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"fake-indexeddb": "^6.2.5",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5",
"postcss": "^8.5.14",
"prettier": "^3.8.3",
"sharp": "^0.34.5",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.6"
}
}

17
playwright.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
timeout: 180_000,
use: {
baseURL: 'http://localhost:4173',
headless: true,
viewport: { width: 1440, height: 900 },
},
webServer: {
command: 'npm run preview',
url: 'http://localhost:4173',
reuseExistingServer: true,
timeout: 30_000,
},
})

12
playwright.prod.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
testMatch: '**/prod-smoke.spec.ts',
timeout: 180_000,
use: {
baseURL: 'https://process-cost-platform.pages.dev',
headless: true,
viewport: { width: 1440, height: 900 },
},
})

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

10
public/favicon.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="7" fill="#1e40af"/>
<rect x="5" y="19" width="5" height="8" rx="1.5" fill="#93c5fd"/>
<rect x="12" y="13" width="5" height="14" rx="1.5" fill="#bfdbfe"/>
<rect x="19" y="7" width="5" height="20" rx="1.5" fill="white"/>
<path d="M7.5 18 L14.5 12 L21.5 6" stroke="#60a5fa" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="7.5" cy="18" r="1.5" fill="#60a5fa"/>
<circle cx="14.5" cy="12" r="1.5" fill="#60a5fa"/>
<circle cx="21.5" cy="6" r="1.5" fill="#60a5fa"/>
</svg>

After

Width:  |  Height:  |  Size: 622 B

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
targetNamespace="http://bpmn.io/schema/bpmn"
id="accent-test-definitions">
<process id="accent-test-process" name="Proceso con Acentos y Ñ" isExecutable="false">
<startEvent id="start" name="Inicio">
<outgoing>flow1</outgoing>
</startEvent>
<task id="task1" name="Análisis de procesos contables">
<incoming>flow1</incoming>
<outgoing>flow2</outgoing>
</task>
<task id="task2" name="Validación de información">
<incoming>flow2</incoming>
<outgoing>flow3</outgoing>
</task>
<task id="task3" name="Inspección visual y funcional">
<incoming>flow3</incoming>
<outgoing>flow4</outgoing>
</task>
<task id="task4" name="Diseño de campañas">
<incoming>flow4</incoming>
<outgoing>flow5</outgoing>
</task>
<endEvent id="end" name="Fin">
<incoming>flow5</incoming>
</endEvent>
<sequenceFlow id="flow1" sourceRef="start" targetRef="task1"/>
<sequenceFlow id="flow2" sourceRef="task1" targetRef="task2"/>
<sequenceFlow id="flow3" sourceRef="task2" targetRef="task3"/>
<sequenceFlow id="flow4" sourceRef="task3" targetRef="task4"/>
<sequenceFlow id="flow5" sourceRef="task4" targetRef="end"/>
</process>
<bpmndi:BPMNDiagram id="diagram">
<bpmndi:BPMNPlane id="plane" bpmnElement="accent-test-process">
<bpmndi:BPMNShape id="start_di" bpmnElement="start">
<dc:Bounds x="152" y="162" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task1_di" bpmnElement="task1">
<dc:Bounds x="240" y="140" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task2_di" bpmnElement="task2">
<dc:Bounds x="390" y="140" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task3_di" bpmnElement="task3">
<dc:Bounds x="540" y="140" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task4_di" bpmnElement="task4">
<dc:Bounds x="690" y="140" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="end_di" bpmnElement="end">
<dc:Bounds x="842" y="162" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="flow1_di" bpmnElement="flow1">
<di:waypoint x="188" y="180"/><di:waypoint x="240" y="180"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow2_di" bpmnElement="flow2">
<di:waypoint x="340" y="180"/><di:waypoint x="390" y="180"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow3_di" bpmnElement="flow3">
<di:waypoint x="490" y="180"/><di:waypoint x="540" y="180"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow4_di" bpmnElement="flow4">
<di:waypoint x="640" y="180"/><di:waypoint x="690" y="180"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow5_di" bpmnElement="flow5">
<di:waypoint x="790" y="180"/><di:waypoint x="842" y="180"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</definitions>

View File

@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
targetNamespace="http://bpmn.io/schema/bpmn"
id="complex-loop-definitions">
<process id="complex-loop-process" name="Proceso de Control de Calidad con Revisión" isExecutable="false">
<startEvent id="start" name="Producto listo para QA">
<outgoing>flow_start_inspeccion</outgoing>
</startEvent>
<task id="task_preparar" name="Preparar lote para inspección">
<incoming>flow_start_inspeccion</incoming>
<outgoing>flow_preparar_inspeccion</outgoing>
</task>
<task id="task_inspeccion" name="Inspección visual y funcional">
<incoming>flow_preparar_inspeccion</incoming>
<incoming>flow_correc_inspeccion</incoming>
<outgoing>flow_inspeccion_gw</outgoing>
</task>
<!-- Gateway XOR: resultado de inspección -->
<exclusiveGateway id="gw_resultado" name="¿Pasa control?">
<incoming>flow_inspeccion_gw</incoming>
<outgoing>flow_gw_aprobado</outgoing>
<outgoing>flow_gw_obs_menores</outgoing>
<outgoing>flow_gw_rechazo</outgoing>
</exclusiveGateway>
<task id="task_documentar" name="Documentar aprobación">
<incoming>flow_gw_aprobado</incoming>
<outgoing>flow_documentar_entrega</outgoing>
</task>
<!-- Loop: correcciones menores → volver a inspección -->
<task id="task_correcciones" name="Realizar correcciones menores">
<incoming>flow_gw_obs_menores</incoming>
<outgoing>flow_correc_inspeccion</outgoing>
</task>
<!-- Gateway XOR: rechazo total -->
<task id="task_rechazo" name="Separar producto rechazado">
<incoming>flow_gw_rechazo</incoming>
<outgoing>flow_rechazo_informe</outgoing>
</task>
<task id="task_informe_rechazo" name="Emitir informe de no conformidad">
<incoming>flow_rechazo_informe</incoming>
<outgoing>flow_informe_merge</outgoing>
</task>
<task id="task_entrega" name="Preparar entrega al cliente">
<incoming>flow_documentar_entrega</incoming>
<outgoing>flow_entrega_certificado</outgoing>
</task>
<task id="task_certificado" name="Emitir certificado de calidad">
<incoming>flow_entrega_certificado</incoming>
<outgoing>flow_cert_merge</outgoing>
</task>
<!-- Convergencia final -->
<exclusiveGateway id="gw_merge" name="Merge final">
<incoming>flow_cert_merge</incoming>
<incoming>flow_informe_merge</incoming>
<outgoing>flow_merge_registro</outgoing>
</exclusiveGateway>
<task id="task_registro" name="Registrar resultado en sistema">
<incoming>flow_merge_registro</incoming>
<outgoing>flow_registro_end</outgoing>
</task>
<endEvent id="end" name="Control de calidad finalizado">
<incoming>flow_registro_end</incoming>
</endEvent>
<!-- Sequence Flows -->
<sequenceFlow id="flow_start_inspeccion" sourceRef="start" targetRef="task_preparar"/>
<sequenceFlow id="flow_preparar_inspeccion" sourceRef="task_preparar" targetRef="task_inspeccion"/>
<sequenceFlow id="flow_inspeccion_gw" sourceRef="task_inspeccion" targetRef="gw_resultado"/>
<sequenceFlow id="flow_gw_aprobado" name="Aprobado" sourceRef="gw_resultado" targetRef="task_documentar"/>
<sequenceFlow id="flow_gw_obs_menores" name="Obs. menores" sourceRef="gw_resultado" targetRef="task_correcciones"/>
<sequenceFlow id="flow_gw_rechazo" name="Rechazado" sourceRef="gw_resultado" targetRef="task_rechazo"/>
<!-- LOOP: correcciones vuelven a inspección -->
<sequenceFlow id="flow_correc_inspeccion" sourceRef="task_correcciones" targetRef="task_inspeccion"/>
<sequenceFlow id="flow_rechazo_informe" sourceRef="task_rechazo" targetRef="task_informe_rechazo"/>
<sequenceFlow id="flow_informe_merge" sourceRef="task_informe_rechazo" targetRef="gw_merge"/>
<sequenceFlow id="flow_documentar_entrega" sourceRef="task_documentar" targetRef="task_entrega"/>
<sequenceFlow id="flow_entrega_certificado" sourceRef="task_entrega" targetRef="task_certificado"/>
<sequenceFlow id="flow_cert_merge" sourceRef="task_certificado" targetRef="gw_merge"/>
<sequenceFlow id="flow_merge_registro" sourceRef="gw_merge" targetRef="task_registro"/>
<sequenceFlow id="flow_registro_end" sourceRef="task_registro" targetRef="end"/>
</process>
<bpmndi:BPMNDiagram id="diagram">
<bpmndi:BPMNPlane id="plane" bpmnElement="complex-loop-process">
<bpmndi:BPMNShape id="start_di" bpmnElement="start"><dc:Bounds x="152" y="272" width="36" height="36"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_preparar_di" bpmnElement="task_preparar"><dc:Bounds x="240" y="250" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_inspeccion_di" bpmnElement="task_inspeccion"><dc:Bounds x="400" y="250" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="gw_resultado_di" bpmnElement="gw_resultado" isMarkerVisible="true"><dc:Bounds x="565" y="265" width="50" height="50"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_documentar_di" bpmnElement="task_documentar"><dc:Bounds x="680" y="140" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_correcciones_di" bpmnElement="task_correcciones"><dc:Bounds x="680" y="390" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_rechazo_di" bpmnElement="task_rechazo"><dc:Bounds x="680" y="530" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_informe_rechazo_di" bpmnElement="task_informe_rechazo"><dc:Bounds x="840" y="530" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_entrega_di" bpmnElement="task_entrega"><dc:Bounds x="840" y="140" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_certificado_di" bpmnElement="task_certificado"><dc:Bounds x="1000" y="140" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="gw_merge_di" bpmnElement="gw_merge" isMarkerVisible="true"><dc:Bounds x="1165" y="265" width="50" height="50"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_registro_di" bpmnElement="task_registro"><dc:Bounds x="1280" y="250" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="end_di" bpmnElement="end"><dc:Bounds x="1452" y="272" width="36" height="36"/></bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="e_start" bpmnElement="flow_start_inspeccion"><di:waypoint x="188" y="290"/><di:waypoint x="240" y="290"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_prep" bpmnElement="flow_preparar_inspeccion"><di:waypoint x="340" y="290"/><di:waypoint x="400" y="290"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_insp_gw" bpmnElement="flow_inspeccion_gw"><di:waypoint x="500" y="290"/><di:waypoint x="565" y="290"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_aprobado" bpmnElement="flow_gw_aprobado"><di:waypoint x="590" y="265"/><di:waypoint x="590" y="180"/><di:waypoint x="680" y="180"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_obs" bpmnElement="flow_gw_obs_menores"><di:waypoint x="590" y="315"/><di:waypoint x="590" y="430"/><di:waypoint x="680" y="430"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_rechazo" bpmnElement="flow_gw_rechazo"><di:waypoint x="590" y="315"/><di:waypoint x="590" y="570"/><di:waypoint x="680" y="570"/></bpmndi:BPMNEdge>
<!-- LOOP back arrow -->
<bpmndi:BPMNEdge id="e_loop" bpmnElement="flow_correc_inspeccion">
<di:waypoint x="730" y="390"/><di:waypoint x="730" y="340"/><di:waypoint x="450" y="340"/><di:waypoint x="450" y="330"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_rec_inf" bpmnElement="flow_rechazo_informe"><di:waypoint x="780" y="570"/><di:waypoint x="840" y="570"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_inf_merge" bpmnElement="flow_informe_merge"><di:waypoint x="940" y="570"/><di:waypoint x="1190" y="570"/><di:waypoint x="1190" y="315"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_doc_entrega" bpmnElement="flow_documentar_entrega"><di:waypoint x="780" y="180"/><di:waypoint x="840" y="180"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_entrega_cert" bpmnElement="flow_entrega_certificado"><di:waypoint x="940" y="180"/><di:waypoint x="1000" y="180"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_cert_merge" bpmnElement="flow_cert_merge"><di:waypoint x="1100" y="180"/><di:waypoint x="1190" y="180"/><di:waypoint x="1190" y="265"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_merge_reg" bpmnElement="flow_merge_registro"><di:waypoint x="1215" y="290"/><di:waypoint x="1280" y="290"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_reg_end" bpmnElement="flow_registro_end"><di:waypoint x="1380" y="290"/><di:waypoint x="1452" y="290"/></bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</definitions>

View File

@@ -0,0 +1,168 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
targetNamespace="http://bpmn.io/schema/bpmn"
id="medium-gateway-definitions">
<process id="medium-gateway-process" name="Proceso de Aprobación de Crédito" isExecutable="false">
<startEvent id="start" name="Solicitud recibida">
<outgoing>flow_start_recv</outgoing>
</startEvent>
<task id="task_recv" name="Recepcionar solicitud">
<incoming>flow_start_recv</incoming>
<outgoing>flow_recv_valid</outgoing>
</task>
<task id="task_valid" name="Validar datos del solicitante">
<incoming>flow_recv_valid</incoming>
<outgoing>flow_valid_gw1</outgoing>
</task>
<!-- Gateway XOR: ¿datos completos? -->
<exclusiveGateway id="gw_datos" name="¿Datos completos?" default="flow_gw1_incompleto">
<incoming>flow_valid_gw1</incoming>
<outgoing>flow_gw1_completo</outgoing>
<outgoing>flow_gw1_incompleto</outgoing>
</exclusiveGateway>
<task id="task_pedir_docs" name="Solicitar documentación adicional">
<incoming>flow_gw1_incompleto</incoming>
<outgoing>flow_docs_join</outgoing>
</task>
<task id="task_score" name="Calcular score crediticio">
<incoming>flow_gw1_completo</incoming>
<outgoing>flow_score_parallel</outgoing>
</task>
<!-- Gateway paralelo: verificación simultánea -->
<parallelGateway id="gw_parallel_split" name="Verificación paralela">
<incoming>flow_score_parallel</incoming>
<outgoing>flow_para_bureau</outgoing>
<outgoing>flow_para_empleador</outgoing>
</parallelGateway>
<task id="task_bureau" name="Consultar buró de crédito">
<incoming>flow_para_bureau</incoming>
<outgoing>flow_bureau_join</outgoing>
</task>
<task id="task_empleador" name="Verificar empleo y salario">
<incoming>flow_para_empleador</incoming>
<outgoing>flow_empleador_join</outgoing>
</task>
<!-- Join paralelo -->
<parallelGateway id="gw_parallel_join" name="Consolidar verificaciones">
<incoming>flow_bureau_join</incoming>
<incoming>flow_empleador_join</incoming>
<outgoing>flow_join_analisis</outgoing>
</parallelGateway>
<task id="task_analisis" name="Analizar riesgo crediticio">
<incoming>flow_join_analisis</incoming>
<incoming>flow_docs_join</incoming>
<outgoing>flow_analisis_decision</outgoing>
</task>
<!-- Gateway XOR: decisión final -->
<exclusiveGateway id="gw_decision" name="Decisión del comité">
<incoming>flow_analisis_decision</incoming>
<outgoing>flow_dec_aprobado</outgoing>
<outgoing>flow_dec_rechazado</outgoing>
</exclusiveGateway>
<task id="task_aprobar" name="Emitir carta de aprobación">
<incoming>flow_dec_aprobado</incoming>
<outgoing>flow_aprobar_desembolso</outgoing>
</task>
<task id="task_rechazar" name="Notificar rechazo al solicitante">
<incoming>flow_dec_rechazado</incoming>
<outgoing>flow_rechazar_fin</outgoing>
</task>
<task id="task_desembolso" name="Procesar desembolso">
<incoming>flow_aprobar_desembolso</incoming>
<outgoing>flow_desembolso_archivo</outgoing>
</task>
<task id="task_archivo" name="Archivar expediente">
<incoming>flow_desembolso_archivo</incoming>
<incoming>flow_rechazar_fin</incoming>
<outgoing>flow_archivo_end</outgoing>
</task>
<endEvent id="end" name="Proceso finalizado">
<incoming>flow_archivo_end</incoming>
</endEvent>
<!-- Sequence Flows -->
<sequenceFlow id="flow_start_recv" sourceRef="start" targetRef="task_recv"/>
<sequenceFlow id="flow_recv_valid" sourceRef="task_recv" targetRef="task_valid"/>
<sequenceFlow id="flow_valid_gw1" sourceRef="task_valid" targetRef="gw_datos"/>
<sequenceFlow id="flow_gw1_completo" name="Completos" sourceRef="gw_datos" targetRef="task_score"/>
<sequenceFlow id="flow_gw1_incompleto" name="Incompletos" sourceRef="gw_datos" targetRef="task_pedir_docs"/>
<sequenceFlow id="flow_docs_join" sourceRef="task_pedir_docs" targetRef="task_analisis"/>
<sequenceFlow id="flow_score_parallel" sourceRef="task_score" targetRef="gw_parallel_split"/>
<sequenceFlow id="flow_para_bureau" sourceRef="gw_parallel_split" targetRef="task_bureau"/>
<sequenceFlow id="flow_para_empleador" sourceRef="gw_parallel_split" targetRef="task_empleador"/>
<sequenceFlow id="flow_bureau_join" sourceRef="task_bureau" targetRef="gw_parallel_join"/>
<sequenceFlow id="flow_empleador_join" sourceRef="task_empleador" targetRef="gw_parallel_join"/>
<sequenceFlow id="flow_join_analisis" sourceRef="gw_parallel_join" targetRef="task_analisis"/>
<sequenceFlow id="flow_analisis_decision" sourceRef="task_analisis" targetRef="gw_decision"/>
<sequenceFlow id="flow_dec_aprobado" name="Aprobado" sourceRef="gw_decision" targetRef="task_aprobar"/>
<sequenceFlow id="flow_dec_rechazado" name="Rechazado" sourceRef="gw_decision" targetRef="task_rechazar"/>
<sequenceFlow id="flow_aprobar_desembolso" sourceRef="task_aprobar" targetRef="task_desembolso"/>
<sequenceFlow id="flow_rechazar_fin" sourceRef="task_rechazar" targetRef="task_archivo"/>
<sequenceFlow id="flow_desembolso_archivo" sourceRef="task_desembolso" targetRef="task_archivo"/>
<sequenceFlow id="flow_archivo_end" sourceRef="task_archivo" targetRef="end"/>
</process>
<bpmndi:BPMNDiagram id="diagram">
<bpmndi:BPMNPlane id="plane" bpmnElement="medium-gateway-process">
<bpmndi:BPMNShape id="start_di" bpmnElement="start"><dc:Bounds x="152" y="252" width="36" height="36"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_recv_di" bpmnElement="task_recv"><dc:Bounds x="240" y="230" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_valid_di" bpmnElement="task_valid"><dc:Bounds x="390" y="230" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="gw_datos_di" bpmnElement="gw_datos" isMarkerVisible="true"><dc:Bounds x="545" y="245" width="50" height="50"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_score_di" bpmnElement="task_score"><dc:Bounds x="650" y="230" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_pedir_docs_di" bpmnElement="task_pedir_docs"><dc:Bounds x="650" y="370" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="gw_parallel_split_di" bpmnElement="gw_parallel_split"><dc:Bounds x="805" y="245" width="50" height="50"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_bureau_di" bpmnElement="task_bureau"><dc:Bounds x="910" y="150" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_empleador_di" bpmnElement="task_empleador"><dc:Bounds x="910" y="310" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="gw_parallel_join_di" bpmnElement="gw_parallel_join"><dc:Bounds x="1065" y="245" width="50" height="50"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_analisis_di" bpmnElement="task_analisis"><dc:Bounds x="1170" y="230" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="gw_decision_di" bpmnElement="gw_decision" isMarkerVisible="true"><dc:Bounds x="1325" y="245" width="50" height="50"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_aprobar_di" bpmnElement="task_aprobar"><dc:Bounds x="1430" y="150" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_rechazar_di" bpmnElement="task_rechazar"><dc:Bounds x="1430" y="310" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_desembolso_di" bpmnElement="task_desembolso"><dc:Bounds x="1590" y="150" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task_archivo_di" bpmnElement="task_archivo"><dc:Bounds x="1750" y="230" width="100" height="80"/></bpmndi:BPMNShape>
<bpmndi:BPMNShape id="end_di" bpmnElement="end"><dc:Bounds x="1912" y="252" width="36" height="36"/></bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="f_start_recv" bpmnElement="flow_start_recv"><di:waypoint x="188" y="270"/><di:waypoint x="240" y="270"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_recv_valid" bpmnElement="flow_recv_valid"><di:waypoint x="340" y="270"/><di:waypoint x="390" y="270"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_valid_gw1" bpmnElement="flow_valid_gw1"><di:waypoint x="490" y="270"/><di:waypoint x="545" y="270"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_gw1_completo" bpmnElement="flow_gw1_completo"><di:waypoint x="595" y="270"/><di:waypoint x="650" y="270"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_gw1_incompleto" bpmnElement="flow_gw1_incompleto"><di:waypoint x="570" y="295"/><di:waypoint x="570" y="410"/><di:waypoint x="650" y="410"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_docs_join" bpmnElement="flow_docs_join"><di:waypoint x="750" y="410"/><di:waypoint x="1220" y="410"/><di:waypoint x="1220" y="310"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_score_parallel" bpmnElement="flow_score_parallel"><di:waypoint x="750" y="270"/><di:waypoint x="805" y="270"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_para_bureau" bpmnElement="flow_para_bureau"><di:waypoint x="830" y="245"/><di:waypoint x="830" y="190"/><di:waypoint x="910" y="190"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_para_empleador" bpmnElement="flow_para_empleador"><di:waypoint x="830" y="295"/><di:waypoint x="830" y="350"/><di:waypoint x="910" y="350"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_bureau_join" bpmnElement="flow_bureau_join"><di:waypoint x="1010" y="190"/><di:waypoint x="1090" y="190"/><di:waypoint x="1090" y="245"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_empleador_join" bpmnElement="flow_empleador_join"><di:waypoint x="1010" y="350"/><di:waypoint x="1090" y="350"/><di:waypoint x="1090" y="295"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_join_analisis" bpmnElement="flow_join_analisis"><di:waypoint x="1115" y="270"/><di:waypoint x="1170" y="270"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_analisis_decision" bpmnElement="flow_analisis_decision"><di:waypoint x="1270" y="270"/><di:waypoint x="1325" y="270"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_dec_aprobado" bpmnElement="flow_dec_aprobado"><di:waypoint x="1350" y="245"/><di:waypoint x="1350" y="190"/><di:waypoint x="1430" y="190"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_dec_rechazado" bpmnElement="flow_dec_rechazado"><di:waypoint x="1350" y="295"/><di:waypoint x="1350" y="350"/><di:waypoint x="1430" y="350"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_aprobar_desembolso" bpmnElement="flow_aprobar_desembolso"><di:waypoint x="1530" y="190"/><di:waypoint x="1590" y="190"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_rechazar_fin" bpmnElement="flow_rechazar_fin"><di:waypoint x="1530" y="350"/><di:waypoint x="1800" y="350"/><di:waypoint x="1800" y="310"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_desembolso_archivo" bpmnElement="flow_desembolso_archivo"><di:waypoint x="1690" y="190"/><di:waypoint x="1800" y="190"/><di:waypoint x="1800" y="230"/></bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="f_archivo_end" bpmnElement="flow_archivo_end"><di:waypoint x="1850" y="270"/><di:waypoint x="1912" y="270"/></bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</definitions>

View File

@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
targetNamespace="http://bpmn.io/schema/bpmn"
id="simple-linear-definitions">
<process id="simple-linear-process" name="Proceso Lineal Simple" isExecutable="false">
<startEvent id="start" name="Inicio">
<outgoing>flow1</outgoing>
</startEvent>
<task id="task1" name="Recibir solicitud">
<incoming>flow1</incoming>
<outgoing>flow2</outgoing>
</task>
<task id="task2" name="Verificar documentación">
<incoming>flow2</incoming>
<outgoing>flow3</outgoing>
</task>
<task id="task3" name="Procesar pedido">
<incoming>flow3</incoming>
<outgoing>flow4</outgoing>
</task>
<task id="task4" name="Notificar al cliente">
<incoming>flow4</incoming>
<outgoing>flow5</outgoing>
</task>
<task id="task5" name="Archivar expediente">
<incoming>flow5</incoming>
<outgoing>flow6</outgoing>
</task>
<endEvent id="end" name="Fin">
<incoming>flow6</incoming>
</endEvent>
<sequenceFlow id="flow1" sourceRef="start" targetRef="task1"/>
<sequenceFlow id="flow2" sourceRef="task1" targetRef="task2"/>
<sequenceFlow id="flow3" sourceRef="task2" targetRef="task3"/>
<sequenceFlow id="flow4" sourceRef="task3" targetRef="task4"/>
<sequenceFlow id="flow5" sourceRef="task4" targetRef="task5"/>
<sequenceFlow id="flow6" sourceRef="task5" targetRef="end"/>
</process>
<bpmndi:BPMNDiagram id="diagram">
<bpmndi:BPMNPlane id="plane" bpmnElement="simple-linear-process">
<bpmndi:BPMNShape id="start_di" bpmnElement="start">
<dc:Bounds x="152" y="162" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task1_di" bpmnElement="task1">
<dc:Bounds x="240" y="140" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task2_di" bpmnElement="task2">
<dc:Bounds x="390" y="140" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task3_di" bpmnElement="task3">
<dc:Bounds x="540" y="140" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task4_di" bpmnElement="task4">
<dc:Bounds x="690" y="140" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="task5_di" bpmnElement="task5">
<dc:Bounds x="840" y="140" width="100" height="80"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="end_di" bpmnElement="end">
<dc:Bounds x="992" y="162" width="36" height="36"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="flow1_di" bpmnElement="flow1">
<di:waypoint x="188" y="180"/><di:waypoint x="240" y="180"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow2_di" bpmnElement="flow2">
<di:waypoint x="340" y="180"/><di:waypoint x="390" y="180"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow3_di" bpmnElement="flow3">
<di:waypoint x="490" y="180"/><di:waypoint x="540" y="180"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow4_di" bpmnElement="flow4">
<di:waypoint x="640" y="180"/><di:waypoint x="690" y="180"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow5_di" bpmnElement="flow5">
<di:waypoint x="790" y="180"/><di:waypoint x="840" y="180"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="flow6_di" bpmnElement="flow6">
<di:waypoint x="940" y="180"/><di:waypoint x="992" y="180"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</definitions>

14
src/App.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { RouterProvider } from '@tanstack/react-router'
import { router } from './router'
import { Toaster } from '@/components/ui/toaster'
export function App() {
return (
<>
<RouterProvider router={router} />
<Toaster />
</>
)
}
export default App

View File

@@ -0,0 +1,9 @@
export function AppFooter() {
return (
<footer className="mt-12 py-4 border-t border-slate-100 text-center">
<p className="text-xs text-slate-300">
Process Cost Platform · v0.1.0 · Hecho desde Paraguay 🇵🇾
</p>
</footer>
)
}

View File

@@ -0,0 +1,30 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,48 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,55 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
})
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,18 @@
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,43 @@
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,147 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,23 @@
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,22 @@
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,56 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
)
)
Table.displayName = 'Table'
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
)
)
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
)
)
TableBody.displayName = 'TableBody'
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
{...props}
/>
)
)
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn('h-10 px-3 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
)
)
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn('px-3 py-2.5 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
)
)
TableCell.displayName = 'TableCell'
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }

View File

@@ -0,0 +1,52 @@
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,98 @@
import * as React from 'react'
import * as ToastPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: { variant: 'default' },
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
))
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn('inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive', className)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn('absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600', className)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold [&+div]:text-xs', className)} {...props} />
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn('text-sm opacity-90', className)} {...props} />
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps, type ToastActionElement,
ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastAction, ToastClose,
}

View File

@@ -0,0 +1,20 @@
import { useToast } from './use-toast'
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from './toast'
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(({ id, title, description, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
<ToastClose />
</Toast>
))}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,27 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,70 @@
import * as React from 'react'
import type { ToastProps } from './toast'
const TOAST_LIMIT = 3
const TOAST_REMOVE_DELAY = 4000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
}
type Action =
| { type: 'ADD_TOAST'; toast: ToasterToast }
| { type: 'DISMISS_TOAST'; toastId?: string }
| { type: 'REMOVE_TOAST'; toastId?: string }
interface State { toasts: ToasterToast[] }
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD_TOAST':
return { ...state, toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT) }
case 'DISMISS_TOAST': {
const { toastId } = action
if (toastId) {
if (!toastTimeouts.has(toastId)) {
toastTimeouts.set(toastId, setTimeout(() => {
dispatch({ type: 'REMOVE_TOAST', toastId })
toastTimeouts.delete(toastId)
}, TOAST_REMOVE_DELAY))
}
}
return { ...state, toasts: state.toasts.map((t) => t.id === toastId || !toastId ? { ...t, open: false } : t) }
}
case 'REMOVE_TOAST':
return { ...state, toasts: action.toastId ? state.toasts.filter((t) => t.id !== action.toastId) : [] }
}
}
const listeners: ((state: State) => void)[] = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((l) => l(memoryState))
}
let count = 0
function genId() { return `${++count}` }
function toast(props: Omit<ToasterToast, 'id'>) {
const id = genId()
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
dispatch({ type: 'ADD_TOAST', toast: { ...props, id, open: true, onOpenChange: (open) => { if (!open) dismiss() } } })
return { id, dismiss }
}
export function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => { const idx = listeners.indexOf(setState); if (idx > -1) listeners.splice(idx, 1) }
}, [])
return { ...state, toast, dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }) }
}
export { toast }

153
src/domain/bpmn-parser.ts Normal file
View File

@@ -0,0 +1,153 @@
import type {
ProcessGraph,
ProcessGraphNode,
ProcessGraphFlow,
BpmnNodeType,
BpmnElementId,
} from './types'
// Mapeo de tags XML BPMN → nuestro tipo normalizado
const TAG_TO_TYPE: Record<string, BpmnNodeType> = {
startevent: 'startEvent',
endevent: 'endEvent',
task: 'task',
usertask: 'userTask',
servicetask: 'serviceTask',
scripttask: 'scriptTask',
manualtask: 'manualTask',
businessruletask: 'businessRuleTask',
subprocess: 'subprocess',
exclusivegateway: 'exclusiveGateway',
parallelgateway: 'parallelGateway',
inclusivegateway: 'inclusiveGateway',
eventbasedgateway: 'eventBasedGateway',
intermediatethrowevent: 'intermediateThrowEvent',
intermediatecatchevent: 'intermediateCatchEvent',
}
function normalizeLocalName(tagName: string): string {
// Eliminar namespace prefix (bpmn:Task → task)
const local = tagName.includes(':') ? tagName.split(':')[1] : tagName
return local.toLowerCase()
}
function getAttr(el: Element, name: string): string {
return el.getAttribute(name) ?? ''
}
function parseFlowRefs(el: Element, tagName: string): string[] {
return Array.from(el.getElementsByTagNameNS('*', tagName)).map((ref) => ref.textContent?.trim() ?? '')
}
export class BpmnParseError extends Error {
constructor(message: string) {
super(message)
this.name = 'BpmnParseError'
}
}
export function parseBpmnXml(xml: string): ProcessGraph {
let doc: Document
try {
const parser = new DOMParser()
doc = parser.parseFromString(xml, 'application/xml')
} catch {
throw new BpmnParseError('El archivo no es un XML válido')
}
const parserError = doc.querySelector('parsererror')
if (parserError) {
throw new BpmnParseError('XML malformado: ' + parserError.textContent?.slice(0, 200))
}
const nodes = new Map<BpmnElementId, ProcessGraphNode>()
const flows = new Map<string, ProcessGraphFlow>()
const startEventIds: BpmnElementId[] = []
// Buscar el proceso principal (puede estar anidado bajo definitions)
const processEl = doc.querySelector('[*|localName="process"], process')
if (!processEl) {
throw new BpmnParseError('No se encontró un elemento <process> en el BPMN')
}
// Iterar todos los hijos directos del proceso
for (const child of Array.from(processEl.children)) {
const local = normalizeLocalName(child.tagName)
const type: BpmnNodeType = TAG_TO_TYPE[local] ?? 'unknown'
if (local === 'sequenceflow') {
const flow: ProcessGraphFlow = {
id: getAttr(child, 'id'),
sourceRef: getAttr(child, 'sourceRef'),
targetRef: getAttr(child, 'targetRef'),
name: getAttr(child, 'name') || undefined,
}
if (flow.id) flows.set(flow.id, flow)
continue
}
if (type === 'unknown') continue
const id = getAttr(child, 'id')
if (!id) continue
const node: ProcessGraphNode = {
id,
name: getAttr(child, 'name'),
type,
outgoing: parseFlowRefs(child, 'outgoing'),
incoming: parseFlowRefs(child, 'incoming'),
}
nodes.set(id, node)
if (type === 'startEvent') startEventIds.push(id)
}
if (nodes.size === 0) {
throw new BpmnParseError('El proceso no contiene elementos reconocibles')
}
if (startEventIds.length === 0) {
throw new BpmnParseError('El proceso no tiene evento de inicio (StartEvent)')
}
return { nodes, flows, startEventIds }
}
// Extrae solo los IDs y nombres de los task/subprocess — util para inicializar actividades
export function extractActivityElements(
graph: ProcessGraph
): Array<{ bpmnElementId: BpmnElementId; name: string; type: 'task' | 'subprocess' }> {
const result = []
for (const node of graph.nodes.values()) {
if (
['task', 'userTask', 'serviceTask', 'scriptTask', 'manualTask', 'businessRuleTask'].includes(node.type)
) {
result.push({ bpmnElementId: node.id, name: node.name || node.id, type: 'task' as const })
} else if (node.type === 'subprocess') {
result.push({ bpmnElementId: node.id, name: node.name || node.id, type: 'subprocess' as const })
}
}
return result
}
// Extrae gateways divergentes (los que tienen >1 outgoing)
export function extractGatewayElements(
graph: ProcessGraph
): Array<{ bpmnElementId: BpmnElementId; name: string; gatewayType: string; outgoing: string[] }> {
const result = []
for (const node of graph.nodes.values()) {
if (
['exclusiveGateway', 'parallelGateway', 'inclusiveGateway', 'eventBasedGateway'].includes(node.type) &&
node.outgoing.length > 1
) {
result.push({
bpmnElementId: node.id,
name: node.name || node.id,
gatewayType: node.type,
outgoing: node.outgoing,
})
}
}
return result
}

54
src/domain/schemas.ts Normal file
View File

@@ -0,0 +1,54 @@
import { z } from 'zod'
export const ResourceSchema = z.object({
id: z.string().uuid(),
processId: z.string().uuid(),
name: z.string().min(1, 'El nombre es requerido'),
type: z.enum(['role', 'person', 'system', 'equipment', 'supply']),
costPerHour: z.number().min(0, 'El costo no puede ser negativo'),
})
export const ActivityResourceAssignmentSchema = z.object({
resourceId: z.string().uuid(),
utilizationPercent: z.number().min(0).max(1),
})
export const ActivitySchema = z.object({
id: z.string().uuid(),
processId: z.string().uuid(),
bpmnElementId: z.string().min(1),
name: z.string(),
type: z.enum(['task', 'subprocess']),
directCostFixed: z.number().min(0),
executionTimeMinutes: z.number().min(0),
assignedResources: z.array(ActivityResourceAssignmentSchema),
})
export const GatewayBranchSchema = z.object({
flowId: z.string(),
targetElementId: z.string(),
probability: z.number().min(0).max(1),
})
export const GatewayConfigSchema = z.object({
id: z.string().uuid(),
processId: z.string().uuid(),
bpmnElementId: z.string().min(1),
gatewayType: z.enum(['exclusive', 'parallel', 'inclusive', 'event-based']),
branches: z.array(GatewayBranchSchema),
})
export const ProcessSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1, 'El nombre del proceso es requerido'),
clientName: z.string(),
bpmnXml: z.string().min(1),
currency: z.string().length(3, 'Código ISO de 3 letras'),
overheadPercentage: z.number().min(0).max(1),
createdAt: z.number(),
updatedAt: z.number(),
})
export type ResourceInput = z.infer<typeof ResourceSchema>
export type ActivityInput = z.infer<typeof ActivitySchema>
export type ProcessInput = z.infer<typeof ProcessSchema>

255
src/domain/simulation.ts Normal file
View File

@@ -0,0 +1,255 @@
import { parseBpmnXml } from './bpmn-parser'
import type {
SimulationInput,
SimulationResult,
ActivitySimResult,
ResourceSimResult,
ProcessGraph,
BpmnElementId,
} from './types'
import { isGatewayNode } from './types'
// ─── Detección de back-edges (loops) via DFS con coloreado ──────────────────
function findBackEdges(graph: ProcessGraph): Set<string> {
const backEdges = new Set<string>()
const WHITE = 0, GRAY = 1, BLACK = 2
const color = new Map<BpmnElementId, number>()
function dfs(nodeId: BpmnElementId) {
color.set(nodeId, GRAY)
const node = graph.nodes.get(nodeId)
if (!node) { color.set(nodeId, BLACK); return }
for (const flowId of node.outgoing) {
const flow = graph.flows.get(flowId)
if (!flow) continue
const targetColor = color.get(flow.targetRef) ?? WHITE
if (targetColor === GRAY) {
backEdges.add(flowId)
} else if (targetColor === WHITE) {
dfs(flow.targetRef)
}
}
color.set(nodeId, BLACK)
}
for (const startId of graph.startEventIds) {
if ((color.get(startId) ?? 0) === 0) dfs(startId)
}
return backEdges
}
// ─── Detección de loops con mensajes accionables ──────────────────────────────
function detectLoops(graph: ProcessGraph, backEdges: Set<string>): string[] {
const warnings: string[] = []
const seen = new Set<string>()
for (const flowId of backEdges) {
const flow = graph.flows.get(flowId)
if (!flow || seen.has(flow.targetRef)) continue
seen.add(flow.targetRef)
const name = graph.nodes.get(flow.targetRef)?.name || flow.targetRef
warnings.push(
`Loop detectado en el proceso: el nodo "${name}" forma un ciclo. Los costos se calcularán asumiendo una sola ejecución del ciclo.`
)
}
return warnings
}
// ─── Sort topológico (Kahn's algorithm, ignora back-edges) ───────────────────
function topologicalSort(graph: ProcessGraph, backEdges: Set<string>): BpmnElementId[] {
const inDegree = new Map<BpmnElementId, number>()
for (const nodeId of graph.nodes.keys()) inDegree.set(nodeId, 0)
for (const [flowId, flow] of graph.flows) {
if (backEdges.has(flowId)) continue
inDegree.set(flow.targetRef, (inDegree.get(flow.targetRef) ?? 0) + 1)
}
const queue: BpmnElementId[] = []
for (const [nodeId, deg] of inDegree) {
if (deg === 0) queue.push(nodeId)
}
const order: BpmnElementId[] = []
while (queue.length > 0) {
const nodeId = queue.shift()!
order.push(nodeId)
const node = graph.nodes.get(nodeId)
if (!node) continue
for (const flowId of node.outgoing) {
if (backEdges.has(flowId)) continue
const flow = graph.flows.get(flowId)
if (!flow) continue
const newDeg = (inDegree.get(flow.targetRef) ?? 0) - 1
inDegree.set(flow.targetRef, newDeg)
if (newDeg === 0) queue.push(flow.targetRef)
}
}
return order
}
// ─── Propagación de probabilidades en orden topológico ───────────────────────
//
// Cada nodo se visita exactamente una vez, con su probabilidad ya acumulada.
// Esto evita el overcounting del BFS (donde nodos convergentes se visitaban
// múltiples veces con probabilidades parciales).
//
// Reglas de acumulación en el nodo TARGET:
// AND-join (parallelGateway convergente): max(existing, branchProb)
// — las ramas paralelas son instancias del mismo flujo, no alternativas.
// Todo lo demás (XOR-join, secuencia, eventos): sum clampeado a 1.0
// — representan caminos mutuamente excluyentes.
function computeExecutionProbabilities(
graph: ProcessGraph,
gatewayProbs: Map<BpmnElementId, Map<string, number>>,
backEdges: Set<string>
): Map<BpmnElementId, number> {
const andJoinIds = new Set<BpmnElementId>()
for (const node of graph.nodes.values()) {
if (node.type === 'parallelGateway' && node.incoming.length > 1 && node.outgoing.length <= 1) {
andJoinIds.add(node.id)
}
}
const probs = new Map<BpmnElementId, number>()
for (const startId of graph.startEventIds) probs.set(startId, 1.0)
const topoOrder = topologicalSort(graph, backEdges)
for (const nodeId of topoOrder) {
const node = graph.nodes.get(nodeId)
if (!node) continue
const nodeProb = probs.get(nodeId) ?? 0
for (const flowId of node.outgoing) {
if (backEdges.has(flowId)) continue
const flow = graph.flows.get(flowId)
if (!flow) continue
let branchProb = nodeProb
if (isGatewayNode(node.type)) {
const gwFlowProbs = gatewayProbs.get(nodeId)
if (gwFlowProbs) {
branchProb = nodeProb * (gwFlowProbs.get(flowId) ?? 0)
} else {
branchProb = node.type === 'parallelGateway'
? nodeProb
: nodeProb / Math.max(node.outgoing.length, 1)
}
}
const targetId = flow.targetRef
const existing = probs.get(targetId) ?? 0
const accumulated = andJoinIds.has(targetId)
? Math.max(existing, branchProb)
: Math.min(existing + branchProb, 1.0)
probs.set(targetId, accumulated)
}
}
return probs
}
// ─── Motor de simulación principal ───────────────────────────────────────────
export function runSimulation(input: SimulationInput): SimulationResult {
const { processXml, activities, gateways, resources, globalSettings } = input
const warnings: string[] = []
const graph = parseBpmnXml(processXml)
const backEdges = findBackEdges(graph)
warnings.push(...detectLoops(graph, backEdges))
const gatewayFlowProbs = new Map<BpmnElementId, Map<string, number>>()
for (const [elemId, gwConfig] of gateways.entries()) {
const flowMap = new Map<string, number>()
for (const branch of gwConfig.branches) flowMap.set(branch.flowId, branch.probability)
gatewayFlowProbs.set(elemId, flowMap)
}
const execProbs = computeExecutionProbabilities(graph, gatewayFlowProbs, backEdges)
const perActivity: ActivitySimResult[] = []
const resourceTotals = new Map<string, { cost: number; minutes: number; name: string }>()
for (const activity of activities.values()) {
const execProb = execProbs.get(activity.bpmnElementId) ?? 0
const node = graph.nodes.get(activity.bpmnElementId)
if (!node) {
warnings.push(
`La actividad "${activity.name}" (${activity.bpmnElementId}) no se encontró en el diagrama y se omitirá del cálculo.`
)
continue
}
const resourceBreakdown: ActivitySimResult['resourceCostBreakdown'] = []
let resourceCostDirect = 0
for (const assignment of activity.assignedResources) {
const resource = resources.get(assignment.resourceId)
if (!resource) continue
const hoursUsed = (activity.executionTimeMinutes / 60) * assignment.utilizationPercent
const cost = resource.costPerHour * hoursUsed * execProb
resourceBreakdown.push({ resourceId: resource.id, resourceName: resource.name, cost })
resourceCostDirect += cost
const prev = resourceTotals.get(resource.id) ?? { cost: 0, minutes: 0, name: resource.name }
resourceTotals.set(resource.id, {
cost: prev.cost + cost,
minutes: prev.minutes + activity.executionTimeMinutes * assignment.utilizationPercent * execProb,
name: resource.name,
})
}
const fixedCostExpected = activity.directCostFixed * execProb
const totalExpectedDirectCost = fixedCostExpected + resourceCostDirect
perActivity.push({
activityId: activity.id,
bpmnElementId: activity.bpmnElementId,
activityName: activity.name || activity.bpmnElementId,
expectedDirectCost: totalExpectedDirectCost,
expectedIndirectCost: 0,
expectedTotalCost: 0,
percentOfTotal: 0,
expectedExecutions: execProb,
executionProbability: execProb,
resourceCostBreakdown: resourceBreakdown,
executionTimeMinutes: activity.executionTimeMinutes,
})
}
const totalDirectCost = perActivity.reduce((s, a) => s + a.expectedDirectCost, 0)
const totalIndirectCost = totalDirectCost * globalSettings.overheadPercentage
const totalCost = totalDirectCost + totalIndirectCost
for (const act of perActivity) {
const directRatio = totalDirectCost > 0 ? act.expectedDirectCost / totalDirectCost : 0
act.expectedIndirectCost = totalIndirectCost * directRatio
act.expectedTotalCost = act.expectedDirectCost + act.expectedIndirectCost
act.percentOfTotal = totalCost > 0 ? (act.expectedTotalCost / totalCost) * 100 : 0
}
perActivity.sort((a, b) => b.expectedTotalCost - a.expectedTotalCost)
const perResource: ResourceSimResult[] = Array.from(resourceTotals.entries()).map(([id, data]) => ({
resourceId: id, resourceName: data.name, totalCost: data.cost, totalMinutesUsed: data.minutes,
}))
const totalTimeMinutes = perActivity.reduce(
(s, a) => s + a.executionTimeMinutes * a.executionProbability,
0
)
return { totalCost, totalDirectCost, totalIndirectCost, totalTimeMinutes, perActivity, perResource, warnings }
}

175
src/domain/types.ts Normal file
View File

@@ -0,0 +1,175 @@
// Tipos de identificadores
export type UUID = string
export type BpmnElementId = string
// ─── Entidades del modelo de dominio ─────────────────────────────────────────
export interface Process {
id: UUID
name: string
clientName: string
bpmnXml: string
currency: string
overheadPercentage: number
createdAt: number
updatedAt: number
}
export type ActivityType = 'task' | 'subprocess'
export interface Activity {
id: UUID
processId: UUID
bpmnElementId: BpmnElementId
name: string
type: ActivityType
directCostFixed: number
executionTimeMinutes: number
assignedResources: ActivityResourceAssignment[]
}
export interface ActivityResourceAssignment {
resourceId: UUID
utilizationPercent: number
}
export type GatewayType = 'exclusive' | 'parallel' | 'inclusive' | 'event-based'
export interface GatewayBranch {
flowId: string
targetElementId: BpmnElementId
probability: number
}
export interface GatewayConfig {
id: UUID
processId: UUID
bpmnElementId: BpmnElementId
gatewayType: GatewayType
branches: GatewayBranch[]
}
export type ResourceType = 'role' | 'person' | 'system' | 'equipment' | 'supply'
export interface Resource {
id: UUID
processId: UUID
name: string
type: ResourceType
costPerHour: number
}
export interface Simulation {
id: UUID
processId: UUID
executedAt: number
result: SimulationResult
}
// ─── Motor de simulación: Input/Output ───────────────────────────────────────
export interface SimulationInput {
processXml: string
activities: Map<BpmnElementId, Activity>
gateways: Map<BpmnElementId, GatewayConfig>
resources: Map<UUID, Resource>
globalSettings: {
currency: string
overheadPercentage: number
}
}
export interface ActivitySimResult {
activityId: UUID
bpmnElementId: BpmnElementId
activityName: string
expectedDirectCost: number
expectedIndirectCost: number
expectedTotalCost: number
percentOfTotal: number
expectedExecutions: number
executionProbability: number
resourceCostBreakdown: Array<{ resourceId: UUID; resourceName: string; cost: number }>
executionTimeMinutes: number
}
export interface ResourceSimResult {
resourceId: UUID
resourceName: string
totalCost: number
totalMinutesUsed: number
}
export interface SimulationResult {
totalCost: number
totalDirectCost: number
totalIndirectCost: number
totalTimeMinutes: number
perActivity: ActivitySimResult[]
perResource: ResourceSimResult[]
warnings: string[]
}
// ─── Grafo en memoria (solo para el parser/motor) ────────────────────────────
export type BpmnNodeType =
| 'startEvent'
| 'endEvent'
| 'task'
| 'userTask'
| 'serviceTask'
| 'scriptTask'
| 'manualTask'
| 'businessRuleTask'
| 'subprocess'
| 'exclusiveGateway'
| 'parallelGateway'
| 'inclusiveGateway'
| 'eventBasedGateway'
| 'intermediateThrowEvent'
| 'intermediateCatchEvent'
| 'unknown'
export interface ProcessGraphNode {
id: BpmnElementId
name: string
type: BpmnNodeType
outgoing: string[]
incoming: string[]
}
export interface ProcessGraphFlow {
id: string
sourceRef: BpmnElementId
targetRef: BpmnElementId
name?: string
}
export interface ProcessGraph {
nodes: Map<BpmnElementId, ProcessGraphNode>
flows: Map<string, ProcessGraphFlow>
startEventIds: BpmnElementId[]
}
// ─── Helpers de clasificación ─────────────────────────────────────────────────
export function isTaskNode(type: BpmnNodeType): boolean {
return ['task', 'userTask', 'serviceTask', 'scriptTask', 'manualTask', 'businessRuleTask', 'subprocess'].includes(type)
}
export function isGatewayNode(type: BpmnNodeType): boolean {
return ['exclusiveGateway', 'parallelGateway', 'inclusiveGateway', 'eventBasedGateway'].includes(type)
}
export function bpmnTypeToActivityType(type: BpmnNodeType): ActivityType {
return type === 'subprocess' ? 'subprocess' : 'task'
}
export function bpmnTypeToGatewayType(type: BpmnNodeType): GatewayType {
switch (type) {
case 'parallelGateway': return 'parallel'
case 'inclusiveGateway': return 'inclusive'
case 'eventBasedGateway': return 'event-based'
default: return 'exclusive'
}
}

View File

@@ -0,0 +1,277 @@
import { useNavigate } from '@tanstack/react-router'
import { useCallback, useState } from 'react'
import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
import { v4 as uuidv4 } from 'uuid'
import { Card, CardContent } from '@/components/ui/card'
import { parseBpmnXml, extractActivityElements, extractGatewayElements, BpmnParseError } from '@/domain/bpmn-parser'
import { processRepo, activityRepo, gatewayRepo } from '@/persistence/repositories'
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'
const SAMPLE_PROCESSES = [
{
id: 'simple-linear',
fileName: 'simple-linear.bpmn',
name: 'Proceso lineal simple',
tags: ['5 tareas', 'Sin gateways'],
},
{
id: 'medium-with-gateways',
fileName: 'medium-with-gateways.bpmn',
name: 'Aprobación de crédito',
tags: ['XOR', 'Paralelo', '11 tareas'],
},
{
id: 'complex-with-loop',
fileName: 'complex-with-loop.bpmn',
name: 'Control de calidad con revisión',
tags: ['Loop', 'XOR', '9 tareas'],
},
]
export function ImportPage() {
const navigate = useNavigate()
const { toast } = useToast()
const [isDragging, setIsDragging] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [loadingSampleId, setLoadingSampleId] = useState<string | null>(null)
const processFile = useCallback(async (file: File) => {
if (!file.name.endsWith('.bpmn') && !file.name.endsWith('.xml')) {
toast({
title: 'Formato no válido',
description: 'El archivo debe tener extensión .bpmn o .xml',
variant: 'destructive',
})
return
}
setIsProcessing(true)
try {
const xml = await file.text()
const graph = parseBpmnXml(xml)
const processId = uuidv4()
const now = Date.now()
const process: Process = {
id: processId,
name: file.name.replace(/\.(bpmn|xml)$/, ''),
clientName: '',
bpmnXml: xml,
currency: 'USD',
overheadPercentage: 0.2,
createdAt: now,
updatedAt: now,
}
const activityElements = extractActivityElements(graph)
const activities: Activity[] = activityElements.map((el) => ({
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
name: el.name,
type: el.type,
directCostFixed: 0,
executionTimeMinutes: 0,
assignedResources: [],
}))
const gatewayElements = extractGatewayElements(graph)
const gateways: GatewayConfig[] = gatewayElements.map((el) => {
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
const n = el.outgoing.length
return {
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
gatewayType: gwType,
branches: el.outgoing.map((flowId) => ({
flowId,
targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
probability: gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(4)),
})),
}
})
await Promise.all([
processRepo.save(process),
activityRepo.saveMany(activities),
gatewayRepo.saveMany(gateways),
])
toast({
title: 'Proceso importado',
description: `${activityElements.length} ${activityElements.length === 1 ? 'actividad' : 'actividades'} detectadas. Configurá los costos para simular.`,
})
navigate({ to: '/workspace/$processId', params: { processId } })
} catch (err) {
setIsProcessing(false)
if (err instanceof BpmnParseError) {
toast({
title: 'BPMN 2.0 no válido',
description: 'El archivo no pudo ser interpretado como un diagrama BPMN 2.0 estándar.',
variant: 'destructive',
})
} else {
toast({
title: 'Error al procesar el archivo',
description: 'Ocurrió un error inesperado. Verificá que el archivo no esté dañado.',
variant: 'destructive',
})
console.error(err)
}
}
}, [navigate, toast])
const onDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) processFile(file)
}, [processFile])
const onFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) processFile(file)
}, [processFile])
async function handleSampleLoad(sample: typeof SAMPLE_PROCESSES[0]) {
setLoadingSampleId(sample.id)
try {
const response = await fetch(`/sample-processes/${sample.fileName}`)
if (!response.ok) throw new Error('No se pudo cargar el proceso de ejemplo')
const text = await response.text()
const file = new File([text], sample.fileName, { type: 'text/xml' })
await processFile(file)
} catch (err) {
toast({
title: 'Error al cargar el ejemplo',
description: 'No se pudo descargar el proceso. Intentá de nuevo.',
variant: 'destructive',
})
console.error(err)
} finally {
setLoadingSampleId(null)
}
}
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">
{/* 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">
<FlaskConical className="h-3.5 w-3.5" />
Process Cost Platform · MVP Fase 0
</div>
<h1 className="text-4xl font-bold text-slate-900 mb-3">
Cuantificá el costo operativo<br />de tus procesos
</h1>
<p className="text-lg text-slate-500 max-w-xl">
Importá un archivo BPMN 2.0 y obtené un reporte visual de costos
listo para presentar a tu cliente en minutos.
</p>
</div>
{/* Drop zone */}
<Card className="w-full max-w-lg shadow-lg">
<CardContent className="p-0">
<label
htmlFor="bpmn-file-input"
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
className={`
flex flex-col items-center justify-center gap-4 p-12 rounded-xl cursor-pointer
border-2 border-dashed transition-all duration-200
${isDragging
? 'border-primary bg-primary/5 scale-[1.01]'
: 'border-slate-200 hover:border-primary/50 hover:bg-slate-50'
}
`}
>
{isProcessing ? (
<>
<Loader2 className="h-12 w-12 text-primary animate-spin" />
<p className="text-slate-600 font-medium">Procesando el diagrama</p>
</>
) : (
<>
<div className={`p-4 rounded-full transition-colors ${isDragging ? 'bg-primary/10' : 'bg-slate-100'}`}>
<UploadCloud className={`h-10 w-10 transition-colors ${isDragging ? 'text-primary' : 'text-slate-400'}`} />
</div>
<div className="text-center">
<p className="text-slate-700 font-semibold text-lg">
Arrastrá tu archivo .bpmn aquí
</p>
<p className="text-slate-400 text-sm mt-1">
o hacé click para seleccionarlo desde tu computadora
</p>
</div>
<div className="flex items-center gap-2 text-xs text-slate-400">
<FileText className="h-3.5 w-3.5" />
BPMN 2.0 XML estándar (.bpmn, .xml)
</div>
</>
)}
<input
id="bpmn-file-input"
type="file"
accept=".bpmn,.xml"
className="hidden"
onChange={onFileInput}
disabled={isProcessing}
/>
</label>
</CardContent>
</Card>
{/* Procesos de ejemplo — visibles directamente */}
<div className="mt-8 w-full max-w-lg">
<p className="text-xs text-slate-400 uppercase tracking-wide font-medium text-center mb-3">
O cargá un proceso de ejemplo con un click
</p>
<div className="grid grid-cols-3 gap-3">
{SAMPLE_PROCESSES.map((sample) => {
const isLoading = loadingSampleId === sample.id
return (
<button
key={sample.id}
onClick={() => handleSampleLoad(sample)}
disabled={isProcessing || loadingSampleId !== null}
className="flex flex-col items-start gap-2 p-3 rounded-xl border border-slate-200 bg-white hover:border-primary/40 hover:shadow-sm transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center justify-between w-full">
<div className="p-1.5 bg-primary/10 rounded-lg">
{isLoading
? <Loader2 className="h-3.5 w-3.5 text-primary animate-spin" />
: <FileText className="h-3.5 w-3.5 text-primary" />
}
</div>
</div>
<p className="text-xs font-semibold text-slate-700 leading-tight">{sample.name}</p>
<div className="flex flex-wrap gap-1">
{sample.tags.map((tag) => (
<span key={tag} className="text-[10px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">
{tag}
</span>
))}
</div>
</button>
)
})}
</div>
</div>
<p className="mt-8 text-xs text-slate-300">
100% en tu navegador · Sin backend · Sin registro · Datos guardados localmente
</p>
<AppFooter />
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useState } from 'react'
import { ArrowLeft, FileText, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
interface SampleProcess {
id: string
fileName: string
name: string
description: string
tags: string[]
}
const SAMPLE_PROCESSES: SampleProcess[] = [
{
id: 'simple-linear',
fileName: 'simple-linear.bpmn',
name: 'Proceso Lineal Simple',
description: '5 tareas en secuencia sin gateways. Ideal para validar el flujo básico de importación y costeo.',
tags: ['Lineal', '5 tareas', 'Sin gateways'],
},
{
id: 'medium-with-gateways',
fileName: 'medium-with-gateways.bpmn',
name: 'Aprobación de Crédito',
description: '12 tareas con un gateway exclusivo (XOR) y un gateway paralelo (AND). Caso de negocio real.',
tags: ['XOR', 'Paralelo', '12 tareas'],
},
{
id: 'complex-with-loop',
fileName: 'complex-with-loop.bpmn',
name: 'Control de Calidad con Revisión',
description: 'Proceso con un loop de correcciones que vuelve a la inspección. Valida la detección de ciclos.',
tags: ['Loop', 'XOR', '9 tareas'],
},
]
interface SampleProcessLoaderProps {
onBack: () => void
onLoad: (file: File) => void
}
export function SampleProcessLoader({ onBack, onLoad }: SampleProcessLoaderProps) {
const [loadingId, setLoadingId] = useState<string | null>(null)
async function handleSelect(sample: SampleProcess) {
setLoadingId(sample.id)
try {
const response = await fetch(`/sample-processes/${sample.fileName}`)
if (!response.ok) throw new Error('No se pudo cargar el archivo de ejemplo')
const text = await response.text()
const file = new File([text], sample.fileName, { type: 'text/xml' })
onLoad(file)
} catch (err) {
console.error('Error cargando proceso de ejemplo:', err)
setLoadingId(null)
}
}
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="w-full max-w-2xl">
<Button variant="ghost" size="sm" onClick={onBack} className="mb-6 text-slate-500">
<ArrowLeft className="h-4 w-4 mr-1.5" />
Volver
</Button>
<div className="mb-8">
<h2 className="text-2xl font-bold text-slate-900 mb-1">Procesos de ejemplo</h2>
<p className="text-slate-500">Seleccioná un proceso para comenzar la demo sin necesitar un archivo propio.</p>
</div>
<div className="flex flex-col gap-4">
{SAMPLE_PROCESSES.map((sample) => (
<Card
key={sample.id}
className="cursor-pointer hover:shadow-md transition-all border-slate-200 hover:border-primary/30"
onClick={() => handleSelect(sample)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<FileText className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{sample.name}</CardTitle>
<CardDescription className="text-xs mt-0.5">{sample.fileName}</CardDescription>
</div>
</div>
{loadingId === sample.id ? (
<Loader2 className="h-5 w-5 text-primary animate-spin mt-1" />
) : (
<Button size="sm" variant="outline" className="shrink-0">
Cargar
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-slate-600 mb-3">{sample.description}</p>
<div className="flex gap-2 flex-wrap">
{sample.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600"
>
{tag}
</span>
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,133 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { activityColor } from '@/lib/colors'
import { formatCurrency, formatPercent, formatMinutes } from '@/lib/format'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
type SortKey = 'activityName' | 'expectedDirectCost' | 'expectedIndirectCost' | 'expectedTotalCost' | 'percentOfTotal' | 'executionTimeMinutes' | 'executionProbability'
interface ActivitiesTableProps {
perActivity: ActivitySimResult[]
mode: HeatmapMode
currency: string
highlightedId: string | null
onRowClick: (bpmnElementId: string) => void
}
function SortIcon({ column, sortKey, dir }: { column: SortKey; sortKey: SortKey; dir: 'asc' | 'desc' }) {
if (column !== sortKey) return <ArrowUpDown className="h-3 w-3 ml-1 opacity-40" />
return dir === 'asc'
? <ArrowUp className="h-3 w-3 ml-1 text-primary" />
: <ArrowDown className="h-3 w-3 ml-1 text-primary" />
}
export function ActivitiesTable({ perActivity, mode, currency, highlightedId, onRowClick }: ActivitiesTableProps) {
const [sortKey, setSortKey] = useState<SortKey>('expectedTotalCost')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const rowRefs = useRef(new Map<string, HTMLTableRowElement>())
function handleSort(key: SortKey) {
if (key === sortKey) {
setSortDir((d) => d === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDir('desc')
}
}
const sorted = useMemo(() => {
return [...perActivity].sort((a, b) => {
const av = a[sortKey]
const bv = b[sortKey]
const mult = sortDir === 'asc' ? 1 : -1
if (typeof av === 'string') return (av as string).localeCompare(bv as string) * mult
return ((av as number) - (bv as number)) * mult
})
}, [perActivity, sortKey, sortDir])
// Scroll a la fila resaltada
useEffect(() => {
if (!highlightedId) return
const row = rowRefs.current.get(highlightedId)
if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [highlightedId])
function Th({ label, column }: { label: string; column: SortKey }) {
return (
<TableHead
className="cursor-pointer select-none hover:text-foreground whitespace-nowrap"
onClick={() => handleSort(column)}
>
<span className="inline-flex items-center text-xs">
{label}
<SortIcon column={column} sortKey={sortKey} dir={sortDir} />
</span>
</TableHead>
)
}
return (
<div className="rounded-xl border border-slate-200 overflow-hidden">
<Table>
<TableHeader className="bg-slate-50">
<tr>
<TableHead className="w-8 text-xs" />
<Th label="Actividad" column="activityName" />
<Th label="Costo directo" column="expectedDirectCost" />
<Th label="Overhead" column="expectedIndirectCost" />
<Th label="Costo total" column="expectedTotalCost" />
<Th label="% total" column="percentOfTotal" />
<Th label="Tiempo" column="executionTimeMinutes" />
<Th label="Prob. ejec." column="executionProbability" />
</tr>
</TableHeader>
<TableBody>
{sorted.map((act) => {
const color = activityColor(act, perActivity, mode)
const isHighlighted = act.bpmnElementId === highlightedId
return (
<TableRow
key={act.activityId}
ref={(el) => { if (el) rowRefs.current.set(act.bpmnElementId, el) }}
onClick={() => onRowClick(act.bpmnElementId)}
className={`cursor-pointer ${isHighlighted ? 'bg-primary/5 ring-1 ring-inset ring-primary/20' : ''}`}
data-state={isHighlighted ? 'selected' : undefined}
>
<TableCell className="py-2">
<div
className="w-3 h-3 rounded-sm flex-shrink-0"
style={{ backgroundColor: color }}
/>
</TableCell>
<TableCell className="text-xs font-medium text-slate-800 max-w-48 truncate">
{act.activityName}
</TableCell>
<TableCell className="text-xs font-mono text-slate-600">
{formatCurrency(act.expectedDirectCost, currency)}
</TableCell>
<TableCell className="text-xs font-mono text-slate-500">
{formatCurrency(act.expectedIndirectCost, currency)}
</TableCell>
<TableCell className="text-xs font-mono font-semibold text-slate-800">
{formatCurrency(act.expectedTotalCost, currency)}
</TableCell>
<TableCell className="text-xs font-mono text-slate-600">
{formatPercent(act.percentOfTotal)}
</TableCell>
<TableCell className="text-xs font-mono text-slate-500">
{act.executionTimeMinutes > 0 ? formatMinutes(act.executionTimeMinutes) : '—'}
</TableCell>
<TableCell className="text-xs font-mono text-slate-500">
{formatPercent(act.executionProbability * 100)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { buildCostByResourceOption } from '@/lib/chart-options'
import type { ActivitySimResult, Resource } from '@/domain/types'
interface CostByResourceChartProps {
perActivity: ActivitySimResult[]
resources: Resource[]
currency: string
}
export function CostByResourceChart({ perActivity, resources, currency }: CostByResourceChartProps) {
const option = useMemo(
() => buildCostByResourceOption(perActivity, resources, currency),
[perActivity, resources, currency]
)
if (!option) {
return (
<div className="flex flex-col items-center justify-center h-64 text-center px-4">
<p className="text-xs text-slate-400 mb-1">Sin recursos asignados</p>
<p className="text-xs text-slate-300">Asigná recursos a las actividades en el workspace para ver este gráfico</p>
</div>
)
}
return <ReactECharts option={option} style={{ height: 300 }} notMerge />
}

View File

@@ -0,0 +1,18 @@
import { useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { buildCostCompositionOption } from '@/lib/chart-options'
import type { SimulationResult } from '@/domain/types'
interface CostCompositionChartProps {
result: SimulationResult
currency: string
}
export function CostCompositionChart({ result, currency }: CostCompositionChartProps) {
const option = useMemo(
() => buildCostCompositionOption(result, currency),
[result, currency]
)
return <ReactECharts option={option} style={{ height: 300 }} notMerge />
}

View File

@@ -0,0 +1,279 @@
import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
import BpmnViewer from 'bpmn-js/lib/Viewer'
import { activityColor } from '@/lib/colors'
import { formatCurrency, formatPercent, formatMinutes } from '@/lib/format'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
interface TooltipData {
visible: boolean
x: number
y: number
activity: ActivitySimResult | null
}
interface HeatmapCanvasProps {
xml: string
perActivity: ActivitySimResult[]
mode: HeatmapMode
currency: string
selectedId: string | null
onActivityClick: (bpmnElementId: string) => void
minHeight?: number
}
export interface HeatmapCanvasHandle {
highlightElement: (bpmnElementId: string | null) => void
captureImage: () => Promise<string | null>
}
function applyHeatmapToViewer(
viewer: any,
perActivity: ActivitySimResult[],
mode: HeatmapMode
) {
const elementRegistry = viewer.get('elementRegistry') as any
if (!elementRegistry) return
for (const act of perActivity) {
const gfx: SVGElement | null = elementRegistry.getGraphics(act.bpmnElementId)
if (!gfx) continue
// activityColor devuelve NEUTRAL_HEATMAP_COLOR cuando no hay varianza significativa,
// evitando una falsa diferenciación en procesos con costos uniformes.
const color = activityColor(act, perActivity, mode)
const shape = gfx.querySelector('.djs-visual rect') as SVGElement | null
if (shape) {
// Usar style.fill (inline CSS) en lugar de setAttribute: el CSS inline tiene
// mayor especificidad que los estilos de bpmn-js, garantizando que html2canvas
// capture el color real (getAttribute devuelve el atributo, pero el estilo
// computado — que usa html2canvas — podría estar sobreescrito por CSS de bpmn-js).
;(shape as any).style.fill = color
;(shape as any).style.fillOpacity = '0.82'
;(shape as any).style.transition = 'fill 0.35s ease, fill-opacity 0.35s ease'
}
}
}
export const HeatmapCanvas = forwardRef<HeatmapCanvasHandle, HeatmapCanvasProps>(
({ xml, perActivity, mode, currency, selectedId, onActivityClick, minHeight = 500 }, ref) => {
const containerRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<any>(null)
const [isImported, setIsImported] = useState(false)
// Ref que espeja isImported — permite que captureImage() lea el valor live
// desde dentro de una closure asíncrona (las closures capturan state por valor).
const isImportedRef = useRef(false)
const [tooltip, setTooltip] = useState<TooltipData>({ visible: false, x: 0, y: 0, activity: null })
const activityMap = useRef(new Map<string, ActivitySimResult>())
useEffect(() => {
activityMap.current = new Map(perActivity.map((a) => [a.bpmnElementId, a]))
}, [perActivity])
// Exponer métodos para resaltar y capturar imagen (para el PDF)
useImperativeHandle(ref, () => ({
highlightElement: (bpmnElementId: string | null) => {
const viewer = viewerRef.current
if (!viewer) return
const canvas = viewer.get('canvas') as any
const reg = viewer.get('elementRegistry') as any
reg.forEach((el: any) => canvas.removeMarker(el.id, 'bpmn-selected'))
if (bpmnElementId) {
try { canvas.addMarker(bpmnElementId, 'bpmn-selected') } catch { /* ignorar */ }
}
},
captureImage: async () => {
if (!containerRef.current) return null
// Esperar a que bpmn-js termine de importar y renderizar (máx. 5 s).
// isImportedRef.current se actualiza sincrónicamente en el .then() de importXML,
// por lo que es visible aquí aunque esta closure se haya creado antes.
if (!isImportedRef.current) {
const TIMEOUT_MS = 5_000
const POLL_MS = 100
let elapsed = 0
while (!isImportedRef.current && elapsed < TIMEOUT_MS) {
await new Promise<void>((resolve) => setTimeout(resolve, POLL_MS))
elapsed += POLL_MS
}
if (!isImportedRef.current) {
throw new Error(
'El diagrama aún no terminó de cargar. Esperá unos segundos y reintentá.'
)
}
}
// Esperar a que React corra el useEffect de applyHeatmapToViewer Y que la
// transición CSS de los fills termine (fill 0.35s ease → necesitamos > 350ms).
await new Promise<void>((resolve) => setTimeout(resolve, 500))
const { default: html2canvas } = await import('html2canvas')
try {
const canvas = await html2canvas(containerRef.current, {
scale: 1.5,
useCORS: true,
allowTaint: true,
backgroundColor: '#f8fafc',
logging: false,
})
return canvas.toDataURL('image/jpeg', 0.82)
} catch (err) {
console.warn('html2canvas error:', err)
return null
}
},
}))
// Crear viewer
useEffect(() => {
if (!containerRef.current) return
const viewer = new BpmnViewer({ container: containerRef.current })
viewerRef.current = viewer
return () => {
viewer.destroy()
viewerRef.current = null
isImportedRef.current = false
setIsImported(false)
}
}, [])
// Importar XML.
// bpmn-js 18 puede rechazar la promesa de importXML (error interno de canvas)
// pero igualmente renderiza el diagrama parcialmente y dispara 'import.done'
// en el eventBus antes de rechazar. Escuchamos 'import.done' para setear
// isImported de forma confiable sin depender de que la promesa resuelva.
useEffect(() => {
const viewer = viewerRef.current
if (!viewer || !xml) return
isImportedRef.current = false
setIsImported(false)
const eventBus = viewer.get('eventBus') as any
const onImportDone = () => {
eventBus.off('import.done', onImportDone)
const canvas = viewer.get('canvas') as any
try { canvas.zoom('fit-viewport', 'auto') } catch { /* canvas puede no estar listo */ }
isImportedRef.current = true
setIsImported(true)
}
// Usamos .on + .off manual en lugar de .once para compatibilidad con mocks de test.
eventBus.on('import.done', onImportDone)
viewer.importXML(xml).catch((err: unknown) => {
// Puede rechazar aunque el diagrama ya se haya renderizado (error de root layer en bpmn-js 18).
// 'import.done' ya habrá disparado antes de este catch, por lo que isImported ya es true.
console.warn('HeatmapCanvas importXML error (diagrama puede estar parcialmente renderizado):', err)
})
return () => {
eventBus.off('import.done', onImportDone)
}
}, [xml])
// Aplicar heatmap cuando el XML está listo o el modo cambia
useEffect(() => {
const viewer = viewerRef.current
if (!isImported || !viewer || perActivity.length === 0) return
applyHeatmapToViewer(viewer, perActivity, mode)
}, [isImported, perActivity, mode])
// Resaltar elemento seleccionado externamente
useEffect(() => {
const viewer = viewerRef.current
if (!isImported || !viewer) return
const canvas = viewer.get('canvas') as any
const reg = viewer.get('elementRegistry') as any
reg.forEach((el: any) => canvas.removeMarker(el.id, 'bpmn-selected'))
if (selectedId) {
try { canvas.addMarker(selectedId, 'bpmn-selected') } catch { /* ignorar */ }
}
}, [isImported, selectedId])
// Event listeners de bpmn-js para click y hover
useEffect(() => {
const viewer = viewerRef.current
if (!isImported || !viewer) return
const eventBus = viewer.get('eventBus') as any
const TASK_TYPES = new Set([
'bpmn:Task', 'bpmn:UserTask', 'bpmn:ServiceTask', 'bpmn:ScriptTask',
'bpmn:ManualTask', 'bpmn:BusinessRuleTask', 'bpmn:SubProcess',
])
const onHover = (event: any) => {
const el = event.element
if (!TASK_TYPES.has(el?.type)) { setTooltip((t) => ({ ...t, visible: false })); return }
const act = activityMap.current.get(el.id)
if (!act) return
setTooltip((t) => ({ ...t, visible: true, activity: act }))
}
const onOut = () => setTooltip((t) => ({ ...t, visible: false }))
const onClick = (event: any) => {
const el = event.element
if (!el || !TASK_TYPES.has(el.type)) return
onActivityClick(el.id)
}
eventBus.on('element.hover', onHover)
eventBus.on('element.out', onOut)
eventBus.on('element.click', onClick)
return () => {
eventBus.off('element.hover', onHover)
eventBus.off('element.out', onOut)
eventBus.off('element.click', onClick)
}
}, [isImported, onActivityClick])
const onMouseMove = useCallback((e: React.MouseEvent) => {
setTooltip((t) => ({ ...t, x: e.clientX, y: e.clientY }))
}, [])
return (
<div className="relative" style={{ minHeight }}>
{/* Canvas bpmn-js */}
<div
ref={containerRef}
onMouseMove={onMouseMove}
className="w-full bpmn-container"
style={{ minHeight, height: '100%' }}
/>
{/* Tooltip custom */}
{tooltip.visible && tooltip.activity && (
<div
className="fixed z-50 pointer-events-none bg-white border border-slate-200 shadow-lg rounded-lg p-3 w-56"
style={{ left: tooltip.x + 14, top: tooltip.y - 70 }}
>
<p className="text-xs font-semibold text-slate-800 mb-2 leading-tight">
{tooltip.activity.activityName}
</p>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-slate-500">Costo esperado</span>
<span className="font-mono font-medium">{formatCurrency(tooltip.activity.expectedTotalCost, currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">% del total</span>
<span className="font-mono">{formatPercent(tooltip.activity.percentOfTotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Prob. ejecución</span>
<span className="font-mono">{formatPercent(tooltip.activity.executionProbability * 100)}</span>
</div>
{tooltip.activity.executionTimeMinutes > 0 && (
<div className="flex justify-between">
<span className="text-slate-500">Tiempo</span>
<span className="font-mono">{formatMinutes(tooltip.activity.executionTimeMinutes)}</span>
</div>
)}
</div>
</div>
)}
</div>
)
}
)
HeatmapCanvas.displayName = 'HeatmapCanvas'

View File

@@ -0,0 +1,35 @@
import { formatCurrency, formatPercent } from '@/lib/format'
import { heatmapLegendBounds } from '@/lib/colors'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
interface HeatmapLegendProps {
perActivity: ActivitySimResult[]
mode: HeatmapMode
currency: string
}
export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProps) {
const bounds = heatmapLegendBounds(perActivity, mode)
function formatBound(value: number): string {
if (bounds.unit === 'percent') return formatPercent(value, value % 1 === 0 ? 0 : 1)
return formatCurrency(value, currency)
}
return (
<div className="flex items-center gap-3 mt-3">
<span className="text-xs text-slate-500 shrink-0">Bajo costo</span>
<div className="flex-1 h-3 rounded-full" style={{
background: 'linear-gradient(to right, #10b981, #f59e0b, #ef4444)'
}} />
<span className="text-xs text-slate-500 shrink-0">Alto costo</span>
<div className="h-4 border-l border-slate-200 mx-1" />
<div className="flex items-center gap-4 text-xs text-slate-400 font-mono">
<span>{formatBound(bounds.min)}</span>
<span>{formatBound(bounds.mid)}</span>
<span>{formatBound(bounds.max)}</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { HeatmapMode } from '@/lib/colors'
interface HeatmapModeToggleProps {
mode: HeatmapMode
onChange: (mode: HeatmapMode) => void
}
const MODE_LABELS: Record<HeatmapMode, { label: string; tip: string }> = {
relative: {
label: 'Costo relativo (%)',
tip: 'Colorea cada actividad en función de su % del costo total. Útil para ver proporciones.',
},
absolute: {
label: 'Costo absoluto',
tip: 'Colorea según el costo esperado en la moneda del proceso. Útil para comparar magnitudes reales.',
},
}
export function HeatmapModeToggle({ mode, onChange }: HeatmapModeToggleProps) {
return (
<div className="inline-flex rounded-lg border border-slate-200 bg-slate-50 p-0.5 text-xs">
{(['relative', 'absolute'] as HeatmapMode[]).map((m) => {
const { label, tip } = MODE_LABELS[m]
return (
<Tooltip key={m}>
<TooltipTrigger asChild>
<button
onClick={() => onChange(m)}
className={cn(
'px-3 py-1.5 rounded-md font-medium transition-all',
mode === m
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
)}
>
{label}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-52 text-xs">
{tip}
</TooltipContent>
</Tooltip>
)
})}
</div>
)
}

View File

@@ -0,0 +1,72 @@
import { DollarSign, TrendingUp, Clock, LayoutList } from 'lucide-react'
import { formatCurrency, formatPercent, formatMinutes } from '@/lib/format'
import type { SimulationResult } from '@/domain/types'
interface KpiBarProps {
result: SimulationResult
currency: string
}
interface KpiCardProps {
label: string
value: string
sub?: string
icon: React.ReactNode
iconColor: string
}
function KpiCard({ label, value, sub, icon, iconColor }: KpiCardProps) {
return (
<div data-testid="kpi-card" className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 flex items-center gap-4">
<div className={`shrink-0 p-3 rounded-lg ${iconColor}`}>
{icon}
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-1">{label}</p>
<p className="text-2xl font-bold text-slate-900 font-mono leading-tight truncate">{value}</p>
{sub && <p className="text-xs text-slate-400 mt-0.5">{sub}</p>}
</div>
</div>
)
}
export function KpiBar({ result, currency }: KpiBarProps) {
const directPct = result.totalCost > 0
? formatPercent(result.totalDirectCost / result.totalCost * 100)
: '—'
const indirectPct = result.totalCost > 0
? formatPercent(result.totalIndirectCost / result.totalCost * 100)
: '—'
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<KpiCard
label="Costo total esperado"
value={formatCurrency(result.totalCost, currency)}
icon={<DollarSign className="h-5 w-5 text-primary" />}
iconColor="bg-primary/10"
/>
<KpiCard
label="Directo / Indirecto"
value={formatCurrency(result.totalDirectCost, currency)}
sub={`${directPct} directo · ${formatCurrency(result.totalIndirectCost, currency)} (${indirectPct}) overhead`}
icon={<TrendingUp className="h-5 w-5 text-emerald-600" />}
iconColor="bg-emerald-50"
/>
<KpiCard
label="Tiempo total esperado"
value={formatMinutes(result.totalTimeMinutes)}
sub={`${result.totalTimeMinutes.toFixed(0)} minutos ponderados por probabilidad`}
icon={<Clock className="h-5 w-5 text-amber-600" />}
iconColor="bg-amber-50"
/>
<KpiCard
label="Actividades costeadas"
value={String(result.perActivity.length)}
sub={result.perActivity.length === 1 ? 'actividad en el proceso' : 'actividades en el proceso'}
icon={<LayoutList className="h-5 w-5 text-slate-500" />}
iconColor="bg-slate-100"
/>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import type { Process } from '@/domain/types'
import { formatPercent } from '@/lib/format'
interface MethodologyFooterProps {
process: Process
}
export function MethodologyFooter({ process }: MethodologyFooterProps) {
return (
<footer className="mt-12 pt-6 border-t border-slate-200">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">Nota metodológica</p>
<div className="text-xs text-slate-400 leading-relaxed space-y-2 max-w-4xl">
<p>
<strong className="text-slate-500">Costo total</strong> = Σ(actividades) costo_directo_esperado + costo_indirecto_esperado.
</p>
<p>
<strong className="text-slate-500">Costo directo por actividad</strong> = (costo_fijo_por_ejecución + Σ(recurso × costo_por_hora × tiempo_de_ejecución × porcentaje_utilización / 60)) × probabilidad_de_ejecución.
</p>
<p>
<strong className="text-slate-500">Costo indirecto</strong> = costo_directo_total × overhead_global ({formatPercent(process.overheadPercentage * 100, 0)}).
Representa costos estructurales no imputables directamente a actividades individuales (alquiler, administración, infraestructura).
</p>
<p>
<strong className="text-slate-500">Probabilidad de ejecución</strong>: calculada mediante propagación hacia adelante desde el evento de inicio,
considerando las probabilidades configuradas en cada gateway de decisión.
Las actividades en el camino principal tienen probabilidad 1 (100%).
Las actividades en ramas condicionales tienen la probabilidad acumulada del camino que las incluye.
</p>
<p className="text-slate-300 pt-1">
Modelo determinístico agregado Noche Cero MVP. Los tiempos y costos son estimaciones.
Para análisis de sensibilidad y distribuciones probabilísticas, ver V1.0.
</p>
</div>
</footer>
)
}

View File

@@ -0,0 +1,67 @@
import { ArrowLeft, Download, FileText, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { formatSimulationTimestamp } from '@/lib/format'
import type { Process } from '@/domain/types'
interface ReportHeaderProps {
process: Process
simulatedAt: number
onBack: () => void
onExportPdf: () => void
onExportCsv: () => void
isExportingPdf?: boolean
isExportingCsv?: boolean
}
export function ReportHeader({
process, simulatedAt, onBack,
onExportPdf, onExportCsv,
isExportingPdf = false, isExportingCsv = false,
}: ReportHeaderProps) {
const { date, time } = formatSimulationTimestamp(simulatedAt)
return (
<div className="flex items-start justify-between gap-6 mb-8">
<div className="min-w-0">
<button
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-600 transition-colors mb-3"
>
<ArrowLeft className="h-3.5 w-3.5" />
Volver a editar
</button>
<h1 className="text-3xl font-bold text-slate-900 leading-tight">{process.name}</h1>
<p className="text-sm text-slate-400 mt-1.5">
{process.clientName && <><span className="text-slate-600">{process.clientName}</span> · </>}
Simulado el {date} a las {time} · Moneda: <span className="font-medium">{process.currency}</span>
</p>
</div>
<div className="flex items-center gap-2 shrink-0 pt-9">
<Button
variant="outline"
size="sm"
onClick={onExportCsv}
disabled={isExportingCsv || isExportingPdf}
className="gap-1.5 min-w-[120px]"
>
{isExportingCsv
? <><Loader2 className="h-4 w-4 animate-spin" />Generando CSV</>
: <><FileText className="h-4 w-4" />Exportar CSV</>
}
</Button>
<Button
size="sm"
onClick={onExportPdf}
disabled={isExportingPdf || isExportingCsv}
className="gap-1.5 min-w-[120px]"
>
{isExportingPdf
? <><Loader2 className="h-4 w-4 animate-spin" />Generando PDF</>
: <><Download className="h-4 w-4" />Exportar PDF</>
}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,278 @@
import { useCallback, useRef, useState } from 'react'
import { useParams, useNavigate } from '@tanstack/react-router'
import { AlertTriangle, Home } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useToast } from '@/components/ui/use-toast'
import { AppFooter } from '@/components/AppFooter'
import { useReportData } from './useReportData'
import { ReportHeader } from './ReportHeader'
import { KpiBar } from './KpiBar'
import { HeatmapCanvas, type HeatmapCanvasHandle } from './HeatmapCanvas'
import { HeatmapLegend } from './HeatmapLegend'
import { HeatmapModeToggle } from './HeatmapModeToggle'
import { WarningsBanner } from './WarningsBanner'
import { TopActivitiesChart } from './TopActivitiesChart'
import { CostCompositionChart } from './CostCompositionChart'
import { CostByResourceChart } from './CostByResourceChart'
import { ActivitiesTable } from './ActivitiesTable'
import { MethodologyFooter } from './MethodologyFooter'
import type { HeatmapMode } from '@/lib/colors'
export function ReportPage() {
const { processId } = useParams({ from: '/report/$processId' })
const navigate = useNavigate()
const { process, simulation, resources, isLoading, error } = useReportData(processId)
const { toast } = useToast()
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>('relative')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isExportingPdf, setIsExportingPdf] = useState(false)
const [isExportingCsv, setIsExportingCsv] = useState(false)
const heatmapRef = useRef<HeatmapCanvasHandle>(null)
const heatmapSectionRef = useRef<HTMLDivElement>(null)
const tableSectionRef = useRef<HTMLDivElement>(null)
// Click en heatmap → resaltar fila en tabla + scroll a tabla
const handleHeatmapClick = useCallback((bpmnElementId: string) => {
setSelectedId(bpmnElementId)
setTimeout(() => {
tableSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 50)
}, [])
// Click en fila de tabla → resaltar en canvas + scroll a heatmap
const handleTableRowClick = useCallback((bpmnElementId: string) => {
setSelectedId(bpmnElementId)
heatmapRef.current?.highlightElement(bpmnElementId)
setTimeout(() => {
heatmapSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 50)
}, [])
const handleExportPdf = useCallback(async () => {
if (!process || !simulation || isExportingPdf) return
setIsExportingPdf(true)
try {
const heatmapImageData = await heatmapRef.current?.captureImage() ?? null
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process, simulation, resources, heatmapImageData })
toast({ title: 'PDF generado', description: 'El reporte se descargó correctamente.' })
} catch (err) {
console.error('PDF export error:', err)
toast({ title: 'Error al generar el PDF', description: String(err), variant: 'destructive' })
} finally {
setIsExportingPdf(false)
}
}, [process, simulation, resources, isExportingPdf, toast])
const handleExportCsv = useCallback(async () => {
if (!process || !simulation || isExportingCsv) return
setIsExportingCsv(true)
try {
const { exportToCsv } = await import('@/lib/export/csv-export')
exportToCsv({ process, simulation, resources })
toast({ title: 'CSV generado', description: 'Los datos se descargaron correctamente.' })
} catch (err) {
console.error('CSV export error:', err)
toast({ title: 'Error al exportar CSV', description: String(err), variant: 'destructive' })
} finally {
setIsExportingCsv(false)
}
}, [process, simulation, resources, isExportingCsv, toast])
// ── Loading — skeleton en lugar de pantalla en blanco ────────────────────
if (isLoading) {
return (
<div className="min-h-screen bg-slate-50">
<div className="max-w-screen-xl mx-auto px-6 py-10 animate-pulse">
{/* Header skeleton */}
<div className="flex items-start justify-between mb-8">
<div className="space-y-2">
<div className="h-3 w-20 bg-slate-200 rounded" />
<div className="h-8 w-80 bg-slate-200 rounded" />
<div className="h-3 w-48 bg-slate-200 rounded" />
</div>
<div className="flex gap-2 pt-9">
<div className="h-9 w-32 bg-slate-200 rounded-md" />
<div className="h-9 w-32 bg-slate-200 rounded-md" />
</div>
</div>
{/* KPI skeleton */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl border border-slate-200 p-6 flex gap-4">
<div className="h-11 w-11 bg-slate-100 rounded-lg shrink-0" />
<div className="flex-1 space-y-2 pt-1">
<div className="h-2.5 bg-slate-100 rounded w-3/4" />
<div className="h-6 bg-slate-200 rounded w-full" />
<div className="h-2 bg-slate-100 rounded w-1/2" />
</div>
</div>
))}
</div>
{/* Canvas skeleton */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="h-5 w-48 bg-slate-200 rounded mb-4" />
<div className="h-72 bg-slate-100 rounded-lg" />
</div>
{/* Table skeleton */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<div className="h-4 w-40 bg-slate-200 rounded mb-4" />
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-10 bg-slate-50 border-b border-slate-100 flex items-center gap-4 px-2">
<div className="h-3 bg-slate-200 rounded flex-1" />
<div className="h-3 bg-slate-200 rounded w-20" />
<div className="h-3 bg-slate-200 rounded w-16" />
</div>
))}
</div>
</div>
</div>
)
}
// ── Error ─────────────────────────────────────────────────────────────────
if (error || !process || !simulation) {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-slate-50 px-6">
<AlertTriangle className="h-10 w-10 text-amber-400" />
<div className="text-center">
<p className="text-sm font-medium text-slate-700 mb-1">No se pudo cargar el reporte</p>
<p className="text-xs text-slate-400 max-w-sm">
{error ?? 'Simulá el proceso desde el workspace para generar el reporte.'}
</p>
</div>
<div className="flex gap-3">
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/' })}>
<Home className="h-4 w-4 mr-1.5" />
Inicio
</Button>
<Button size="sm" onClick={() => navigate({ to: '/workspace/$processId', params: { processId } })}>
Volver a configurar y simular
</Button>
</div>
</div>
)
}
const result = simulation.result
const perActivity = result.perActivity
return (
<TooltipProvider>
<div className="min-h-screen bg-slate-50">
<div className="max-w-screen-xl mx-auto px-6 py-10">
{/* ── 1. Header ─────────────────────────────────────────────── */}
<ReportHeader
process={process}
simulatedAt={simulation.executedAt}
onBack={() => navigate({ to: '/workspace/$processId', params: { processId } })}
onExportPdf={handleExportPdf}
onExportCsv={handleExportCsv}
isExportingPdf={isExportingPdf}
isExportingCsv={isExportingCsv}
/>
{/* ── 2. KPI Bar ────────────────────────────────────────────── */}
<KpiBar result={result} currency={process.currency} />
{/* ── 3. Mapa de Calor ──────────────────────────────────────── */}
<div ref={heatmapSectionRef} className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-base font-semibold text-slate-800">Mapa de calor de costos</h2>
<p className="text-xs text-slate-400 mt-0.5">
Hacé click en una actividad para ver su detalle en la tabla
</p>
</div>
<HeatmapModeToggle mode={heatmapMode} onChange={setHeatmapMode} />
</div>
<HeatmapCanvas
ref={heatmapRef}
xml={process.bpmnXml}
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
selectedId={selectedId}
onActivityClick={handleHeatmapClick}
minHeight={520}
/>
<HeatmapLegend
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
/>
</div>
{/* ── 4. Warnings ───────────────────────────────────────────── */}
<WarningsBanner warnings={result.warnings} />
{/* ── 5. Análisis — 3 gráficos ──────────────────────────────── */}
<div className="mb-6">
<h2 className="text-base font-semibold text-slate-800 mb-4">Análisis de composición</h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
Top actividades por costo
</h3>
<TopActivitiesChart
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
/>
</div>
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
Composición directo / overhead
</h3>
<CostCompositionChart result={result} currency={process.currency} />
</div>
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
Costo por tipo de recurso
</h3>
<CostByResourceChart
perActivity={perActivity}
resources={resources}
currency={process.currency}
/>
</div>
</div>
</div>
{/* ── 6. Tabla detalle ──────────────────────────────────────── */}
<div ref={tableSectionRef} className="mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-slate-800">
Detalle por actividad
<span className="ml-2 text-sm font-normal text-slate-400">({perActivity.length} actividades)</span>
</h2>
</div>
<ActivitiesTable
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
highlightedId={selectedId}
onRowClick={handleTableRowClick}
/>
</div>
<Separator className="my-4" />
{/* ── 7. Footer metodológico ────────────────────────────────── */}
<MethodologyFooter process={process} />
<AppFooter />
</div>
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,24 @@
import { useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { buildTopActivitiesOption } from '@/lib/chart-options'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
interface TopActivitiesChartProps {
perActivity: ActivitySimResult[]
mode: HeatmapMode
currency: string
}
export function TopActivitiesChart({ perActivity, mode, currency }: TopActivitiesChartProps) {
const option = useMemo(
() => buildTopActivitiesOption(perActivity, mode, currency),
[perActivity, mode, currency]
)
if (perActivity.length === 0) {
return <div className="flex items-center justify-center h-64 text-xs text-slate-400">Sin datos de actividades</div>
}
return <ReactECharts option={option} style={{ height: 320 }} notMerge />
}

View File

@@ -0,0 +1,36 @@
import { AlertTriangle } from 'lucide-react'
interface WarningsBannerProps {
warnings: string[]
}
export function WarningsBanner({ warnings }: WarningsBannerProps) {
if (warnings.length === 0) return null
const hasLoop = warnings.some((w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo'))
return (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-6">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-semibold text-amber-800 mb-2">
{warnings.length === 1 ? 'Advertencia del motor de simulación' : `${warnings.length} advertencias del motor de simulación`}
</p>
<ul className="space-y-1">
{warnings.map((w, i) => (
<li key={i} className="text-sm text-amber-700 leading-relaxed"> {w}</li>
))}
</ul>
{hasLoop && (
<p className="text-xs text-amber-600 mt-2 border-t border-amber-200 pt-2">
<strong>Impacto en los resultados:</strong> Los procesos con ciclos se calculan asumiendo una sola ejecución del loop.
Los costos reales pueden ser mayores si el loop se repite múltiples veces en cada instancia del proceso.
Para modelado probabilístico de loops, se implementará distribuciones en V1.0.
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
import { processRepo, activityRepo, resourceRepo, simulationRepo } from '@/persistence/repositories'
import type { Process, Activity, Resource, Simulation } from '@/domain/types'
interface ReportData {
process: Process | null
simulation: Simulation | null
activities: Activity[]
resources: Resource[]
isLoading: boolean
error: string | null
}
export function useReportData(processId: string): ReportData {
const [process, setProcess] = useState<Process | null>(null)
const [simulation, setSimulation] = useState<Simulation | null>(null)
const [activities, setActivities] = useState<Activity[]>([])
const [resources, setResources] = useState<Resource[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
setIsLoading(true)
setError(null)
try {
const [proc, sim, acts, res] = await Promise.all([
processRepo.getById(processId),
simulationRepo.getLatestByProcess(processId),
activityRepo.getByProcess(processId),
resourceRepo.getByProcess(processId),
])
if (cancelled) return
if (!proc) throw new Error('Proceso no encontrado en la base de datos local')
if (!sim) throw new Error('Sin simulación disponible. Volvé al workspace y ejecutá "Simular" primero.')
setProcess(proc)
setSimulation(sim)
setActivities(acts)
setResources(res)
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Error al cargar el reporte')
} finally {
if (!cancelled) setIsLoading(false)
}
}
load()
return () => { cancelled = true }
}, [processId])
return { process, simulation, activities, resources, isLoading, error }
}

View File

@@ -0,0 +1,56 @@
import { useCallback } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { useProcessStore } from '@/store/process-store'
import { useSimulationStore } from '@/store/simulation-store'
import { runSimulation } from '@/domain/simulation'
import { simulationRepo } from '@/persistence/repositories'
import type { SimulationInput } from '@/domain/types'
export function useSimulate() {
const { currentProcess, activities, gateways, resources } = useProcessStore()
const { setResult, setRunning, setError } = useSimulationStore()
const simulate = useCallback(async () => {
if (!currentProcess) {
setError('No hay proceso cargado')
return null
}
setRunning(true)
setError(null)
try {
const input: SimulationInput = {
processXml: currentProcess.bpmnXml,
activities: new Map(activities.map((a) => [a.bpmnElementId, a])),
gateways: new Map(gateways.map((g) => [g.bpmnElementId, g])),
resources: new Map(resources.map((r) => [r.id, r])),
globalSettings: {
currency: currentProcess.currency,
overheadPercentage: currentProcess.overheadPercentage,
},
}
const result = runSimulation(input)
const simulation = {
id: uuidv4(),
processId: currentProcess.id,
executedAt: Date.now(),
result,
}
await simulationRepo.save(simulation)
setResult(result)
setRunning(false)
return result
} catch (err) {
const message = err instanceof Error ? err.message : 'Error en la simulación'
setError(message)
setRunning(false)
return null
}
}, [currentProcess, activities, gateways, resources, setResult, setRunning, setError])
return { simulate }
}

View File

@@ -0,0 +1,241 @@
import { useEffect, useState } from 'react'
import { PlusCircle, Trash2, Info } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useProcessStore } from '@/store/process-store'
import { formatCurrency } from '@/lib/format'
import type { Activity } from '@/domain/types'
interface ActivityPanelProps {
selectedElementId: string | null
}
export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
const { activities, resources, updateActivity } = useProcessStore()
const [localActivity, setLocalActivity] = useState<Activity | null>(null)
const [isDirty, setIsDirty] = useState(false)
const activity = activities.find((a) => a.bpmnElementId === selectedElementId) ?? null
// Sincronizar con el store cuando cambia la selección
useEffect(() => {
setLocalActivity(activity ? { ...activity, assignedResources: [...activity.assignedResources] } : null)
setIsDirty(false)
}, [selectedElementId, activities])
function handleChange<K extends keyof Activity>(key: K, value: Activity[K]) {
if (!localActivity) return
setLocalActivity((prev) => prev ? { ...prev, [key]: value } : null)
setIsDirty(true)
}
async function handleSave() {
if (!localActivity) return
await updateActivity(localActivity)
setIsDirty(false)
}
function addResource(resourceId: string) {
if (!localActivity) return
const already = localActivity.assignedResources.some((r) => r.resourceId === resourceId)
if (already) return
handleChange('assignedResources', [
...localActivity.assignedResources,
{ resourceId, utilizationPercent: 1.0 },
])
}
function removeResource(resourceId: string) {
if (!localActivity) return
handleChange(
'assignedResources',
localActivity.assignedResources.filter((r) => r.resourceId !== resourceId)
)
}
function setUtilization(resourceId: string, value: number) {
if (!localActivity) return
handleChange(
'assignedResources',
localActivity.assignedResources.map((r) =>
r.resourceId === resourceId ? { ...r, utilizationPercent: value } : r
)
)
}
if (!selectedElementId) {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-12">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5" />
</svg>
</div>
<p className="text-sm font-medium text-slate-600 mb-1">Seleccioná una actividad</p>
<p className="text-xs text-slate-400">Hacé click sobre una tarea en el diagrama para editar sus atributos de costo</p>
</div>
)
}
if (!localActivity) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-xs text-slate-400">Esta actividad no tiene configuración todavía</p>
</div>
)
}
const availableResources = resources.filter(
(r) => !localActivity.assignedResources.some((ar) => ar.resourceId === r.id)
)
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-5">
{/* Nombre (read-only, viene del BPMN) */}
<div>
<Label className="text-xs text-slate-500 mb-1 block">Actividad</Label>
<p className="font-semibold text-slate-800 text-sm leading-tight">{localActivity.name || localActivity.bpmnElementId}</p>
<p className="text-xs text-slate-400 mt-0.5 font-mono">{localActivity.bpmnElementId}</p>
</div>
{/* Costo directo fijo */}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="direct-cost" className="text-xs font-medium">Costo directo fijo</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-48 text-xs">
Costo fijo por cada ejecución de esta actividad (materiales, licencias, etc.) independiente del tiempo
</TooltipContent>
</Tooltip>
</div>
<Input
id="direct-cost"
type="number"
min={0}
step={10}
value={localActivity.directCostFixed}
onChange={(e) => handleChange('directCostFixed', Math.max(0, parseFloat(e.target.value) || 0))}
className="font-mono h-8 text-sm"
placeholder="0"
/>
</div>
{/* Tiempo de ejecución */}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="exec-time" className="text-xs font-medium">Tiempo de ejecución (min)</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-48 text-xs">
Tiempo promedio estimado para completar esta actividad
</TooltipContent>
</Tooltip>
</div>
<Input
id="exec-time"
type="number"
min={0}
step={5}
value={localActivity.executionTimeMinutes}
onChange={(e) => handleChange('executionTimeMinutes', Math.max(0, parseFloat(e.target.value) || 0))}
className="font-mono h-8 text-sm"
placeholder="0"
/>
</div>
{/* Recursos asignados */}
<div className="space-y-2">
<Label className="text-xs font-medium block">Recursos asignados</Label>
{localActivity.assignedResources.length === 0 ? (
<p className="text-xs text-slate-400 py-2">Sin recursos asignados</p>
) : (
<div className="space-y-3">
{localActivity.assignedResources.map((assignment) => {
const resource = resources.find((r) => r.id === assignment.resourceId)
if (!resource) return null
const hourlyShare = (resource.costPerHour * assignment.utilizationPercent).toFixed(2)
return (
<div key={assignment.resourceId} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-700">{resource.name}</p>
<p className="text-xs text-slate-400">{formatCurrency(resource.costPerHour)}/h · {formatCurrency(parseFloat(hourlyShare))}/h efectivo</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-slate-400 hover:text-destructive"
onClick={() => removeResource(assignment.resourceId)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-xs text-slate-500">Utilización</span>
<span className="text-xs font-mono font-medium">{Math.round(assignment.utilizationPercent * 100)}%</span>
</div>
<Slider
value={[assignment.utilizationPercent * 100]}
min={0}
max={100}
step={5}
onValueChange={([v]) => setUtilization(assignment.resourceId, v / 100)}
/>
</div>
</div>
)
})}
</div>
)}
{/* Agregar recurso */}
{availableResources.length > 0 && (
<Select onValueChange={addResource}>
<SelectTrigger className="h-8 text-xs">
<div className="flex items-center gap-1.5 text-slate-500">
<PlusCircle className="h-3.5 w-3.5" />
<SelectValue placeholder="Agregar recurso…" />
</div>
</SelectTrigger>
<SelectContent>
{availableResources.map((r) => (
<SelectItem key={r.id} value={r.id} className="text-xs">
{r.name} {formatCurrency(r.costPerHour)}/h
</SelectItem>
))}
</SelectContent>
</Select>
)}
{resources.length === 0 && (
<p className="text-xs text-slate-400">Creá recursos en la tab "Recursos" para asignarlos</p>
)}
</div>
{/* Guardar */}
<Button
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar cambios' : 'Sin cambios'}
</Button>
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,82 @@
import { useEffect, useRef } from 'react'
import BpmnViewer from 'bpmn-js/lib/Viewer'
export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
const ACTIVITY_TYPES = new Set([
'bpmn:Task', 'bpmn:UserTask', 'bpmn:ServiceTask', 'bpmn:ScriptTask',
'bpmn:ManualTask', 'bpmn:BusinessRuleTask', 'bpmn:SubProcess',
])
const GATEWAY_TYPES = new Set([
'bpmn:ExclusiveGateway', 'bpmn:ParallelGateway',
'bpmn:InclusiveGateway', 'bpmn:EventBasedGateway',
])
function classifyElement(type: string): BpmnElementCategory {
if (ACTIVITY_TYPES.has(type)) return 'activity'
if (GATEWAY_TYPES.has(type)) return 'gateway'
return 'other'
}
interface BpmnCanvasProps {
xml: string
onElementClick?: (elementId: string, elementName: string, category: BpmnElementCategory) => void
selectedElementId?: string | null
className?: string
}
export function BpmnCanvas({ xml, onElementClick, selectedElementId, className }: BpmnCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<InstanceType<typeof BpmnViewer> | null>(null)
useEffect(() => {
if (!containerRef.current) return
const viewer = new BpmnViewer({ container: containerRef.current })
viewerRef.current = viewer
return () => {
viewer.destroy()
viewerRef.current = null
}
}, [])
useEffect(() => {
const viewer = viewerRef.current
if (!viewer || !xml) return
viewer.importXML(xml).then(({ warnings }) => {
if (warnings.length) console.warn('bpmn-js warnings:', warnings)
const canvas = viewer.get('canvas') as any
canvas.zoom('fit-viewport', 'auto')
}).catch((err: Error) => console.error('Error cargando BPMN:', err))
}, [xml])
useEffect(() => {
const viewer = viewerRef.current
if (!viewer || !onElementClick) return
const eventBus = viewer.get('eventBus') as any
const handler = (event: any) => {
const el = event.element
if (!el) return
const category = classifyElement(el.type)
if (category === 'other') return
onElementClick(el.id, el.businessObject?.name ?? el.id, category)
}
eventBus.on('element.click', handler)
return () => eventBus.off('element.click', handler)
}, [onElementClick])
useEffect(() => {
const viewer = viewerRef.current
if (!viewer) return
const canvas = viewer.get('canvas') as any
const reg = viewer.get('elementRegistry') as any
reg.forEach((el: any) => canvas.removeMarker(el.id, 'bpmn-selected'))
if (selectedElementId) {
try { canvas.addMarker(selectedElementId, 'bpmn-selected') } catch { /* ignorar */ }
}
}, [selectedElementId])
return <div ref={containerRef} className={className} style={{ width: '100%', height: '100%' }} />
}

View File

@@ -0,0 +1,277 @@
import { useEffect, useMemo, useState } from 'react'
import { Info, AlertTriangle, RotateCcw, Check, GitMerge } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useProcessStore } from '@/store/process-store'
import { parseBpmnXml } from '@/domain/bpmn-parser'
import { formatPercent } from '@/lib/format'
import type { GatewayConfig } from '@/domain/types'
interface GatewayPanelProps {
selectedElementId: string | null
}
// Tolerancia para validar que la suma de probs XOR = 1.0
const XOR_SUM_TOLERANCE = 0.005
function sumBranches(gw: GatewayConfig): number {
return gw.branches.reduce((s, b) => s + b.probability, 0)
}
export function isXorSumValid(gw: GatewayConfig): boolean {
if (gw.gatewayType !== 'exclusive') return true
return Math.abs(sumBranches(gw) - 1.0) <= XOR_SUM_TOLERANCE
}
export function GatewayPanel({ selectedElementId }: GatewayPanelProps) {
const { gateways, currentProcess, updateGateway, activities } = useProcessStore()
const [localGw, setLocalGw] = useState<GatewayConfig | null>(null)
const [isDirty, setIsDirty] = useState(false)
// Mapa bpmnElementId → nombre legible (parseamos el XML una sola vez)
const elementNames = useMemo(() => {
if (!currentProcess) return new Map<string, string>()
try {
const graph = parseBpmnXml(currentProcess.bpmnXml)
return new Map(Array.from(graph.nodes.values()).map((n) => [n.id, n.name || n.id]))
} catch {
return new Map<string, string>()
}
}, [currentProcess?.bpmnXml])
const gateway = gateways.find((g) => g.bpmnElementId === selectedElementId) ?? null
useEffect(() => {
setLocalGw(gateway ? structuredClone(gateway) : null)
setIsDirty(false)
}, [selectedElementId, gateways])
function targetLabel(targetId: string): string {
// Primero buscar en actividades (tienen nombre más descriptivo)
const act = activities.find((a) => a.bpmnElementId === targetId)
if (act?.name) return act.name
// Luego en el mapa del grafo
const fromGraph = elementNames.get(targetId)
if (fromGraph) return fromGraph
return targetId
}
function setProbability(branchIndex: number, value: number) {
if (!localGw) return
const updated = structuredClone(localGw)
updated.branches[branchIndex].probability = value
setLocalGw(updated)
setIsDirty(true)
}
function distributeUniformly() {
if (!localGw) return
const n = localGw.branches.length
const equalProb = parseFloat((1 / n).toFixed(4))
const updated = structuredClone(localGw)
updated.branches = updated.branches.map((b, i) => ({
...b,
probability: i === n - 1 ? parseFloat((1 - equalProb * (n - 1)).toFixed(4)) : equalProb,
}))
setLocalGw(updated)
setIsDirty(true)
}
async function handleSave() {
if (!localGw) return
await updateGateway(localGw)
setIsDirty(false)
}
// ── Empty state ──────────────────────────────────────────────────────────
if (!selectedElementId) {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-12">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mb-3">
<GitMerge className="h-6 w-6 text-slate-400" />
</div>
<p className="text-sm font-medium text-slate-600 mb-1">Seleccioná un gateway</p>
<p className="text-xs text-slate-400">Hacé click sobre un gateway en el diagrama para configurar sus probabilidades</p>
</div>
)
}
if (!localGw) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-xs text-slate-400">Gateway sin configuración</p>
</div>
)
}
// ── AND Paralelo: informativo, sin sliders ────────────────────────────────
if (localGw.gatewayType === 'parallel') {
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Gateway Paralelo (AND)</span>
</div>
<p className="text-xs font-medium text-slate-800 font-mono">{localGw.bpmnElementId}</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-semibold text-blue-800">Ejecución paralela</p>
<p className="text-xs text-blue-700 mt-0.5">
En un gateway AND todas las salidas se ejecutan simultáneamente.
No hay probabilidades configurables la probabilidad de cada rama
es igual a la probabilidad de llegada al gateway.
</p>
</div>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium">Flujos de salida</Label>
{localGw.branches.map((branch) => (
<div key={branch.flowId} className="flex items-center justify-between p-2.5 rounded-lg bg-slate-50 border border-slate-100">
<div className="min-w-0">
<p className="text-xs font-medium text-slate-700 truncate">{targetLabel(branch.targetElementId)}</p>
<p className="text-xs text-slate-400 font-mono">{branch.flowId}</p>
</div>
<span className="text-xs font-mono font-semibold text-blue-600 shrink-0 ml-2">100%</span>
</div>
))}
</div>
</div>
</ScrollArea>
)
}
// ── XOR Exclusivo / OR Inclusivo (con aviso) ──────────────────────────────
const isInclusive = localGw.gatewayType === 'inclusive'
const currentSum = sumBranches(localGw)
const xorValid = isInclusive || Math.abs(currentSum - 1.0) <= XOR_SUM_TOLERANCE
const sumColor = xorValid ? 'text-green-600' : 'text-destructive'
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
{/* Header */}
<div>
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide block mb-1">
{isInclusive ? 'Gateway Inclusivo (OR)' : 'Gateway Exclusivo (XOR)'}
</span>
<p className="text-xs font-medium text-slate-800 font-mono">{localGw.bpmnElementId}</p>
</div>
{/* Aviso para OR inclusivo */}
{isInclusive && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<p className="text-xs text-amber-800">
<span className="font-semibold">Gateway Inclusivo (OR)</span> En el MVP se simula como
XOR: solo una rama se toma por instancia. Las probabilidades deben sumar 1.
El soporte OR nativo llega en V1.0.
</p>
</div>
)}
{/* Suma actual + botón distribuir */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500">Suma de probabilidades:</span>
<span className={`text-xs font-mono font-bold ${sumColor}`}>
{formatPercent(currentSum * 100, 1)}
</span>
{!xorValid && (
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className="h-3.5 w-3.5 text-destructive cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-44 text-xs">
La suma debe ser exactamente 100% para poder simular
</TooltipContent>
</Tooltip>
)}
{xorValid && (
<Tooltip>
<TooltipTrigger asChild>
<Check className="h-3.5 w-3.5 text-green-500 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
Suma 100% listo para simular
</TooltipContent>
</Tooltip>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1 text-slate-500"
onClick={distributeUniformly}
>
<RotateCcw className="h-3 w-3" />
Distribuir uniformemente
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-52 text-xs">
Reparte el 100% en partes iguales entre todas las ramas del gateway
</TooltipContent>
</Tooltip>
</div>
{/* Sliders por rama */}
<div className="space-y-4">
{localGw.branches.map((branch, idx) => (
<div key={branch.flowId} className="space-y-1.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-medium text-slate-700 truncate leading-tight">
{targetLabel(branch.targetElementId)}
</p>
<p className="text-[10px] text-slate-400 font-mono truncate">{branch.flowId}</p>
</div>
<span className="text-xs font-mono font-semibold text-slate-700 shrink-0 w-11 text-right tabular-nums">
{formatPercent(branch.probability * 100, 1)}
</span>
</div>
<Slider
value={[Math.round(branch.probability * 1000) / 10]}
min={0}
max={100}
step={1}
onValueChange={([v]) => setProbability(idx, parseFloat((v / 100).toFixed(4)))}
/>
</div>
))}
</div>
{/* Error de suma — banner prominente */}
{!xorValid && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-3 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<p className="text-xs text-destructive">
La suma es <strong>{formatPercent(currentSum * 100, 1)}</strong> debe ser 100%.
Ajustá los sliders o usá "Distribuir" para repartir uniformemente.
El botón <strong>Simular</strong> quedará deshabilitado hasta corregirlo.
</p>
</div>
)}
<Button
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar probabilidades' : 'Sin cambios'}
</Button>
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,140 @@
import { useState, useEffect } from 'react'
import { Info } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useProcessStore } from '@/store/process-store'
import { formatPercent } from '@/lib/format'
const CURRENCIES = [
{ code: 'USD', label: 'USD — Dólar estadounidense' },
{ code: 'PYG', label: 'PYG — Guaraní paraguayo' },
{ code: 'BRL', label: 'BRL — Real brasileño' },
{ code: 'ARS', label: 'ARS — Peso argentino' },
{ code: 'EUR', label: 'EUR — Euro' },
{ code: 'COP', label: 'COP — Peso colombiano' },
{ code: 'MXN', label: 'MXN — Peso mexicano' },
{ code: 'CLP', label: 'CLP — Peso chileno' },
]
export function GlobalSettingsPanel() {
const { currentProcess, updateProcess } = useProcessStore()
const [localName, setLocalName] = useState('')
const [localClient, setLocalClient] = useState('')
const [localCurrency, setLocalCurrency] = useState('USD')
const [localOverhead, setLocalOverhead] = useState(20)
const [isDirty, setIsDirty] = useState(false)
useEffect(() => {
if (!currentProcess) return
setLocalName(currentProcess.name)
setLocalClient(currentProcess.clientName)
setLocalCurrency(currentProcess.currency)
setLocalOverhead(Math.round(currentProcess.overheadPercentage * 100))
setIsDirty(false)
}, [currentProcess])
function markDirty() { setIsDirty(true) }
async function handleSave() {
await updateProcess({
name: localName.trim() || 'Proceso sin nombre',
clientName: localClient.trim(),
currency: localCurrency,
overheadPercentage: localOverhead / 100,
})
setIsDirty(false)
}
if (!currentProcess) return null
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-5">
{/* Nombre del proceso */}
<div className="space-y-1.5">
<Label htmlFor="proc-name" className="text-xs font-medium">Nombre del proceso</Label>
<Input
id="proc-name"
value={localName}
onChange={(e) => { setLocalName(e.target.value); markDirty() }}
placeholder="ej. Proceso de ventas"
className="h-8 text-sm"
/>
</div>
{/* Cliente */}
<div className="space-y-1.5">
<Label htmlFor="client-name" className="text-xs font-medium">Cliente</Label>
<Input
id="client-name"
value={localClient}
onChange={(e) => { setLocalClient(e.target.value); markDirty() }}
placeholder="ej. Empresa ABC"
className="h-8 text-sm"
/>
</div>
{/* Moneda */}
<div className="space-y-1.5">
<Label className="text-xs font-medium">Moneda</Label>
<Select value={localCurrency} onValueChange={(v) => { setLocalCurrency(v); markDirty() }}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CURRENCIES.map((c) => (
<SelectItem key={c.code} value={c.code} className="text-xs">{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Overhead */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium">Overhead (costos indirectos)</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-52 text-xs">
Porcentaje adicional sobre los costos directos que representa los costos indirectos: alquiler, administración, infraestructura general.
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-3">
<Slider
value={[localOverhead]}
min={0}
max={100}
step={1}
onValueChange={([v]) => { setLocalOverhead(v); markDirty() }}
className="flex-1"
/>
<span className="text-sm font-mono font-semibold text-slate-700 w-12 text-right">
{formatPercent(localOverhead, 0)}
</span>
</div>
<p className="text-xs text-slate-400">
Típicamente entre 15% y 40% para empresas de servicios
</p>
</div>
{/* Guardar */}
<Button
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar configuración' : 'Sin cambios'}
</Button>
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,201 @@
import { useState } from 'react'
import { PlusCircle, Pencil, Trash2, Check, X } from 'lucide-react'
import { v4 as uuidv4 } from 'uuid'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useProcessStore } from '@/store/process-store'
import { formatCurrency } from '@/lib/format'
import type { Resource, ResourceType } from '@/domain/types'
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
role: 'Rol',
person: 'Persona',
system: 'Sistema',
equipment: 'Equipo',
supply: 'Insumo',
}
const RESOURCE_TYPE_COLORS: Record<ResourceType, string> = {
role: 'bg-blue-100 text-blue-700',
person: 'bg-green-100 text-green-700',
system: 'bg-purple-100 text-purple-700',
equipment: 'bg-amber-100 text-amber-700',
supply: 'bg-slate-100 text-slate-700',
}
interface ResourceFormState {
name: string
type: ResourceType
costPerHour: string
}
const EMPTY_FORM: ResourceFormState = { name: '', type: 'role', costPerHour: '' }
export function ResourcesPanel() {
const { currentProcess, resources, addResource, updateResource, deleteResource } = useProcessStore()
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState<ResourceFormState>(EMPTY_FORM)
function startAdd() {
setForm(EMPTY_FORM)
setEditingId(null)
setShowForm(true)
}
function startEdit(resource: Resource) {
setForm({ name: resource.name, type: resource.type, costPerHour: resource.costPerHour.toString() })
setEditingId(resource.id)
setShowForm(true)
}
function cancelForm() {
setShowForm(false)
setEditingId(null)
setForm(EMPTY_FORM)
}
async function handleSubmit() {
if (!form.name.trim() || !currentProcess) return
const costPerHour = parseFloat(form.costPerHour) || 0
if (editingId) {
const existing = resources.find((r) => r.id === editingId)
if (!existing) return
await updateResource({ ...existing, name: form.name.trim(), type: form.type, costPerHour })
} else {
const newResource: Resource = {
id: uuidv4(),
processId: currentProcess.id,
name: form.name.trim(),
type: form.type,
costPerHour,
}
await addResource(newResource)
}
cancelForm()
}
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-700">Recursos del proceso</p>
<p className="text-xs text-slate-400">{resources.length} definido{resources.length !== 1 ? 's' : ''}</p>
</div>
{!showForm && (
<Button size="sm" variant="outline" onClick={startAdd} className="h-7 text-xs gap-1">
<PlusCircle className="h-3.5 w-3.5" />
Nuevo
</Button>
)}
</div>
{/* Formulario */}
{showForm && (
<div className="border border-primary/20 bg-primary/5 rounded-lg p-3 space-y-3">
<p className="text-xs font-semibold text-slate-700">{editingId ? 'Editar recurso' : 'Nuevo recurso'}</p>
<div className="space-y-1.5">
<Label className="text-xs">Nombre</Label>
<Input
autoFocus
placeholder="ej. Analista Senior"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="h-8 text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Tipo</Label>
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v as ResourceType }))}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.entries(RESOURCE_TYPE_LABELS) as [ResourceType, string][]).map(([value, label]) => (
<SelectItem key={value} value={value} className="text-xs">{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Costo por hora</Label>
<Input
type="number"
min={0}
step={5}
placeholder="0.00"
value={form.costPerHour}
onChange={(e) => setForm((f) => ({ ...f, costPerHour: e.target.value }))}
className="h-8 text-xs font-mono"
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleSubmit} disabled={!form.name.trim()} className="flex-1 h-7 text-xs gap-1">
<Check className="h-3.5 w-3.5" />
{editingId ? 'Actualizar' : 'Crear'}
</Button>
<Button size="sm" variant="ghost" onClick={cancelForm} className="h-7 text-xs">
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
{/* Lista */}
{resources.length === 0 && !showForm ? (
<div className="text-center py-8">
<p className="text-xs text-slate-400 mb-2">Sin recursos definidos</p>
<p className="text-xs text-slate-300">Los recursos (roles, sistemas, equipos) se asignan a actividades para calcular su costo por hora</p>
</div>
) : (
<div className="space-y-2">
{resources.map((resource) => (
<div
key={resource.id}
className="flex items-center justify-between p-3 rounded-lg border border-slate-100 hover:border-slate-200 bg-white group"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<p className="text-xs font-medium text-slate-800 truncate">{resource.name}</p>
<span className={`shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full ${RESOURCE_TYPE_COLORS[resource.type]}`}>
{RESOURCE_TYPE_LABELS[resource.type]}
</span>
</div>
<p className="text-xs text-slate-500 font-mono">{formatCurrency(resource.costPerHour)}/hora</p>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-slate-400 hover:text-slate-600"
onClick={() => startEdit(resource)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-slate-400 hover:text-destructive"
onClick={() => deleteResource(resource.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,293 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { useParams, useNavigate } from '@tanstack/react-router'
import {
Loader2, BarChart3, Play, AlertTriangle,
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'
import { Separator } from '@/components/ui/separator'
import { useToast } from '@/components/ui/use-toast'
import { BpmnCanvas, type BpmnElementCategory } from './BpmnCanvas'
import { ActivityPanel } from './ActivityPanel'
import { GatewayPanel, isXorSumValid } from './GatewayPanel'
import { ResourcesPanel } from './ResourcesPanel'
import { GlobalSettingsPanel } from './GlobalSettingsPanel'
import { useProcessStore } from '@/store/process-store'
import { useSimulationStore } from '@/store/simulation-store'
import { useSimulate } from '../simulation/useSimulate'
type SelectedCategory = 'activity' | 'gateway' | null
export function WorkspacePage() {
const { processId } = useParams({ from: '/workspace/$processId' })
const navigate = useNavigate()
const { currentProcess, isLoading, error, loadProcess, gateways } = useProcessStore()
const { isRunning, result, error: simError, selectActivity } = useSimulationStore()
const { simulate } = useSimulate()
const { toast } = useToast()
const [sidebarOpen, setSidebarOpen] = useState(true)
const [selectedElementId, setSelectedElementId] = useState<string | null>(null)
const [selectedElementName, setSelectedElementName] = useState<string>('')
const [selectedCategory, setSelectedCategory] = useState<SelectedCategory>(null)
const [activeTab, setActiveTab] = useState('elemento')
// 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).
const hasLoadedOnce = useRef(false)
useEffect(() => { loadProcess(processId) }, [processId, loadProcess])
useEffect(() => {
if (isLoading) { hasLoadedOnce.current = true; return }
if (!hasLoadedOnce.current) return // carga no iniciada aún
if ((error || !currentProcess) && processId) {
toast({
title: 'Proceso no encontrado',
description: 'El proceso que buscás no existe o fue eliminado.',
variant: 'destructive',
})
navigate({ to: '/' })
}
}, [isLoading, error, currentProcess, processId, toast, navigate])
// Validación XOR: todos los gateways exclusivos deben sumar 1.0
const xorValidation = useMemo(() => {
const invalidIds = gateways
.filter((gw) => gw.gatewayType === 'exclusive' && !isXorSumValid(gw))
.map((gw) => gw.bpmnElementId)
return { valid: invalidIds.length === 0, invalidIds }
}, [gateways])
const canSimulate = !isRunning && xorValidation.valid
const handleElementClick = useCallback((
elementId: string,
elementName: string,
category: BpmnElementCategory
) => {
if (category === 'other') return
setSelectedElementId(elementId)
setSelectedElementName(elementName)
setSelectedCategory(category)
selectActivity(category === 'activity' ? elementId : null)
setActiveTab('elemento')
if (!sidebarOpen) setSidebarOpen(true)
}, [selectActivity, sidebarOpen])
function clearSelection() {
setSelectedElementId(null)
setSelectedElementName('')
setSelectedCategory(null)
selectActivity(null)
}
async function handleSimulate() {
if (!canSimulate) return
const simResult = await simulate()
if (simResult) navigate({ to: '/report/$processId', params: { processId } })
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (error || !currentProcess) {
return null // el useEffect ya inició el redirect con toast
}
// Label del tab "elemento" según lo seleccionado
const elementTabLabel = selectedCategory === 'gateway' ? 'Gateway' : 'Actividad'
const elementTabIcon = selectedCategory === 'gateway'
? <GitMerge className="h-3 w-3 mr-1" />
: <MousePointer2 className="h-3 w-3 mr-1" />
return (
<TooltipProvider>
<div className="h-screen flex flex-col bg-slate-100 overflow-hidden">
{/* ── Topbar ─────────────────────────────────────────────────── */}
<header className="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: '/' })}>
<Home className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Inicio</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-800 truncate">{currentProcess.name}</p>
{currentProcess.clientName && (
<p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
)}
</div>
{/* Errores de simulación */}
{simError && (
<div className="flex items-center gap-1.5 text-destructive text-xs bg-destructive/10 px-2 py-1 rounded">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{simError}
</div>
)}
{/* Advertencia de XOR inválido */}
{!xorValidation.valid && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 text-amber-700 text-xs bg-amber-50 border border-amber-200 px-2 py-1 rounded cursor-help">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{xorValidation.invalidIds.length} gateway{xorValidation.invalidIds.length > 1 ? 's' : ''} con probabilidades inválidas
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-60 text-xs">
<p className="font-semibold mb-1">Gateways XOR que no suman 100%:</p>
{xorValidation.invalidIds.map((id) => (
<p key={id} className="font-mono"> {id}</p>
))}
<p className="mt-1 text-slate-300">Ajustá las probabilidades para habilitar la simulación</p>
</TooltipContent>
</Tooltip>
)}
{/* Ver reporte si ya hay simulación */}
{result && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs gap-1.5 text-primary border-primary/30"
onClick={() => navigate({ to: '/report/$processId', params: { processId } })}
>
<BarChart3 className="h-3.5 w-3.5" />
Ver reporte
</Button>
)}
{/* Simular */}
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
className="h-8 text-xs gap-1.5"
onClick={handleSimulate}
disabled={!canSimulate}
>
{isRunning
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Simulando</>
: <><Play className="h-3.5 w-3.5" />Simular</>
}
</Button>
</span>
</TooltipTrigger>
{!xorValidation.valid && (
<TooltipContent side="bottom" className="text-xs">
Corregí las probabilidades de los gateways XOR primero
</TooltipContent>
)}
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setSidebarOpen((v) => !v)}
>
{sidebarOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{sidebarOpen ? 'Cerrar panel' : 'Abrir panel'}</TooltipContent>
</Tooltip>
</header>
{/* ── Main ───────────────────────────────────────────────────── */}
<div className="flex-1 flex overflow-hidden relative">
{/* Canvas BPMN */}
<div className="flex-1 relative overflow-hidden bpmn-container">
<BpmnCanvas
xml={currentProcess.bpmnXml}
onElementClick={handleElementClick}
selectedElementId={selectedElementId}
className="absolute inset-0"
/>
{/* Chip de elemento seleccionado */}
{selectedElementId && (
<div className="absolute top-3 left-3 bg-white/90 backdrop-blur-sm border border-slate-200 rounded-lg px-3 py-1.5 shadow-sm flex items-center gap-2 max-w-xs">
<div className={`w-2 h-2 rounded-full shrink-0 ${selectedCategory === 'gateway' ? 'bg-amber-500' : 'bg-primary'}`} />
<span className="text-xs font-medium text-slate-700 truncate">{selectedElementName || selectedElementId}</span>
<button onClick={clearSelection} className="ml-1 text-slate-400 hover:text-slate-600 shrink-0">×</button>
</div>
)}
{/* Hint inicial */}
{!selectedElementId && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white/80 backdrop-blur-sm border border-slate-200 rounded-full px-4 py-2 shadow-sm">
<p className="text-xs text-slate-500">Hacé click en una tarea o gateway para configurarlo</p>
</div>
)}
</div>
{/* Panel lateral */}
<div className={`
shrink-0 bg-white border-l border-slate-200 flex flex-col overflow-hidden
transition-all duration-300 ease-in-out
${sidebarOpen ? 'w-72' : 'w-0'}
`}>
{sidebarOpen && (
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<TabsList className="shrink-0 mx-3 mt-3 grid grid-cols-3 w-auto">
<TabsTrigger value="elemento" className="text-xs flex items-center">
{elementTabIcon}{elementTabLabel}
</TabsTrigger>
<TabsTrigger value="recursos" className="text-xs">Recursos</TabsTrigger>
<TabsTrigger value="global" className="text-xs">Global</TabsTrigger>
</TabsList>
<TabsContent value="elemento" className="flex-1 overflow-hidden mt-0 data-[state=active]:flex data-[state=active]:flex-col">
{selectedCategory === 'gateway'
? <GatewayPanel selectedElementId={selectedElementId} />
: <ActivityPanel selectedElementId={selectedElementId} />
}
</TabsContent>
<TabsContent value="recursos" className="flex-1 overflow-hidden mt-0 data-[state=active]:flex data-[state=active]:flex-col">
<ResourcesPanel />
</TabsContent>
<TabsContent value="global" className="flex-1 overflow-hidden mt-0 data-[state=active]:flex data-[state=active]:flex-col">
<GlobalSettingsPanel />
</TabsContent>
</Tabs>
)}
</div>
{/* Toggle flotante cuando sidebar cerrado */}
{!sidebarOpen && (
<button
onClick={() => setSidebarOpen(true)}
className="absolute right-0 top-1/2 -translate-y-1/2 bg-white border border-slate-200 border-r-0 rounded-l-lg p-1.5 shadow-md hover:bg-slate-50 z-20"
>
<PanelRightOpen className="h-4 w-4 text-slate-500" />
</button>
)}
</div>
</div>
</TooltipProvider>
)
}

242
src/lib/chart-options.ts Normal file
View File

@@ -0,0 +1,242 @@
import { activityColor, type HeatmapMode } from './colors'
import { formatCurrency } from './format'
import type { ActivitySimResult, Resource, ResourceType, SimulationResult } from '@/domain/types'
// Nombres y colores por tipo de recurso — única fuente de verdad
export const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
role: 'Roles', person: 'Personas', system: 'Sistemas',
equipment: 'Equipos', supply: 'Insumos',
}
export const RESOURCE_TYPE_COLORS: Record<ResourceType, string> = {
role: '#3b82f6', person: '#10b981', system: '#8b5cf6',
equipment: '#f59e0b', supply: '#64748b',
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + '…' : s
}
// ─── Top actividades por costo — barras horizontales ─────────────────────────
export function buildTopActivitiesOption(
perActivity: ActivitySimResult[],
mode: HeatmapMode,
currency: string
): Record<string, unknown> {
const top10 = perActivity.slice(0, 10)
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: unknown[]) => {
const p = params[0] as { dataIndex: number; value: number }
const act = top10[p.dataIndex]
return `<div style="font-size:12px">
<div style="font-weight:600;margin-bottom:4px">${act.activityName}</div>
<div>Costo esperado: <strong>${formatCurrency(act.expectedTotalCost, currency)}</strong></div>
<div>% del total: ${act.percentOfTotal.toFixed(1)}%</div>
<div>Directo: ${formatCurrency(act.expectedDirectCost, currency)}</div>
<div>Indirecto: ${formatCurrency(act.expectedIndirectCost, currency)}</div>
</div>`
},
},
grid: { left: 12, right: 24, top: 8, bottom: 8, containLabel: true },
xAxis: {
type: 'value',
axisLabel: {
fontSize: 11,
formatter: (v: number) => {
if (v === 0) return '0'
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
if (v >= 1_000) return `${(v / 1_000).toFixed(0)}k`
return String(v)
},
},
splitLine: { lineStyle: { color: '#f1f5f9' } },
},
yAxis: {
type: 'category',
data: top10.map((a) => truncate(a.activityName, 28)),
inverse: true,
axisLabel: { fontSize: 11, color: '#475569', width: 160, overflow: 'truncate' },
axisTick: { show: false },
axisLine: { show: false },
},
series: [{
type: 'bar',
data: top10.map((a) => ({
value: a.expectedTotalCost,
itemStyle: { color: activityColor(a, perActivity, mode) },
})),
barMaxWidth: 28,
label: {
show: true,
position: 'right',
fontSize: 10,
color: '#64748b',
formatter: (p: { value: number }) => formatCurrency(p.value, currency),
},
}],
}
}
// ─── Composición directo / indirecto — donut ──────────────────────────────────
export function buildCostCompositionOption(
result: Pick<SimulationResult, 'totalDirectCost' | 'totalIndirectCost' | 'totalCost'>,
currency: string
): Record<string, unknown> {
const hasIndirect = result.totalIndirectCost > 0
return {
tooltip: {
trigger: 'item',
formatter: (p: { name: string; value: number; percent: number }) =>
`<div style="font-size:12px">
<div style="font-weight:600;margin-bottom:4px">${p.name}</div>
<div>${formatCurrency(p.value, currency)} (${p.percent.toFixed(1)}%)</div>
</div>`,
},
legend: {
bottom: '2%',
left: 'center',
itemWidth: 12,
itemHeight: 12,
textStyle: { fontSize: 11, color: '#475569' },
},
series: [{
type: 'pie',
radius: ['48%', '68%'],
center: ['50%', '44%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: {
show: true,
fontSize: 11,
formatter: (p: { percent: number }) => `${p.percent.toFixed(1)}%`,
},
labelLine: { length: 10, length2: 8 },
emphasis: { label: { fontSize: 13, fontWeight: 'bold' } },
data: [
{ value: result.totalDirectCost, name: 'Costo directo', itemStyle: { color: '#3b82f6' } },
...(hasIndirect ? [{ value: result.totalIndirectCost, name: 'Costo indirecto (overhead)', itemStyle: { color: '#94a3b8' } }] : []),
],
}],
graphic: [{
type: 'text',
left: 'center',
top: '38%',
style: {
text: formatCurrency(result.totalCost, currency),
textAlign: 'center',
fontSize: 13,
fontWeight: 'bold',
fill: '#1e293b',
fontFamily: 'JetBrains Mono, monospace',
},
}],
}
}
// ─── Costo por tipo de recurso — barras verticales ────────────────────────────
interface ResourceTypeTotals {
byType: Partial<Record<ResourceType, number>>
byTypeDetails: Partial<Record<ResourceType, { name: string; cost: number }[]>>
activeTypes: ResourceType[]
}
export function aggregateResourceCosts(
perActivity: ActivitySimResult[],
resources: Resource[]
): ResourceTypeTotals {
const resourceById = new Map(resources.map((r) => [r.id, r]))
const byType: Partial<Record<ResourceType, number>> = {}
const byTypeDetails: Partial<Record<ResourceType, { name: string; cost: number }[]>> = {}
for (const act of perActivity) {
for (const rb of act.resourceCostBreakdown) {
if (rb.cost <= 0) continue
const res = resourceById.get(rb.resourceId)
if (!res) continue
byType[res.type] = (byType[res.type] ?? 0) + rb.cost
if (!byTypeDetails[res.type]) byTypeDetails[res.type] = []
const existing = byTypeDetails[res.type]!.find((d) => d.name === res.name)
if (existing) existing.cost += rb.cost
else byTypeDetails[res.type]!.push({ name: res.name, cost: rb.cost })
}
}
const activeTypes = (Object.keys(RESOURCE_TYPE_LABELS) as ResourceType[]).filter(
(t) => (byType[t] ?? 0) > 0
)
return { byType, byTypeDetails, activeTypes }
}
export function buildCostByResourceOption(
perActivity: ActivitySimResult[],
resources: Resource[],
currency: string
): Record<string, unknown> | null {
const { byType, byTypeDetails, activeTypes } = aggregateResourceCosts(perActivity, resources)
if (activeTypes.length === 0) return null
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: unknown[]) => {
const p = params[0] as { dataIndex: number; value: number }
const type = activeTypes[p.dataIndex]
const dets = byTypeDetails[type] ?? []
const detHtml = dets
.sort((a, b) => b.cost - a.cost)
.map((d) => `<div style="font-size:11px;padding-left:8px">• ${d.name}: ${formatCurrency(d.cost, currency)}</div>`)
.join('')
return `<div style="font-size:12px">
<div style="font-weight:600;margin-bottom:4px">${RESOURCE_TYPE_LABELS[type]}</div>
<div>Total: <strong>${formatCurrency(p.value, currency)}</strong></div>${detHtml}
</div>`
},
},
grid: { left: 12, right: 12, top: 12, bottom: 8, containLabel: true },
xAxis: {
type: 'category',
data: activeTypes.map((t) => RESOURCE_TYPE_LABELS[t]),
axisLabel: { fontSize: 11, color: '#475569' },
axisTick: { show: false },
axisLine: { lineStyle: { color: '#e2e8f0' } },
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 10,
color: '#94a3b8',
formatter: (v: number) => {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
if (v >= 1_000) return `${(v / 1_000).toFixed(0)}k`
return String(v)
},
},
splitLine: { lineStyle: { color: '#f1f5f9' } },
},
series: [{
type: 'bar',
data: activeTypes.map((t) => ({
value: byType[t] ?? 0,
itemStyle: { color: RESOURCE_TYPE_COLORS[t], borderRadius: [4, 4, 0, 0] },
})),
barMaxWidth: 48,
label: {
show: true,
position: 'top',
fontSize: 10,
color: '#64748b',
formatter: (p: { value: number }) => formatCurrency(p.value, currency),
},
}],
}
}

149
src/lib/colors.ts Normal file
View File

@@ -0,0 +1,149 @@
// Paleta heatmap: verde → amarillo → rojo
const HEATMAP_LOW = { r: 16, g: 185, b: 129 } // #10b981
const HEATMAP_MID = { r: 245, g: 158, b: 11 } // #f59e0b
const HEATMAP_HIGH = { r: 239, g: 68, b: 68 } // #ef4444
function lerp(a: number, b: number, t: number): number {
return Math.round(a + (b - a) * t)
}
// t en [0, 1]
export function heatmapColor(t: number): string {
const clamped = Math.max(0, Math.min(1, t))
let r: number, g: number, b: number
if (clamped <= 0.5) {
const localT = clamped * 2
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
} else {
const localT = (clamped - 0.5) * 2
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
}
return `rgb(${r}, ${g}, ${b})`
}
export function heatmapColorHex(t: number): string {
const clamped = Math.max(0, Math.min(1, t))
let r: number, g: number, b: number
if (clamped <= 0.5) {
const localT = clamped * 2
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
} else {
const localT = (clamped - 0.5) * 2
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
}
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
}
// Retorna opacidad para colores con alpha (útil para overlays sobre bpmn-js)
export function heatmapColorWithAlpha(t: number, alpha = 0.75): string {
const clamped = Math.max(0, Math.min(1, t))
let r: number, g: number, b: number
if (clamped <= 0.5) {
const localT = clamped * 2
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
} else {
const localT = (clamped - 0.5) * 2
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
}
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
// Determina si el texto sobre el color debe ser claro u oscuro (WCAG contrast)
export function getContrastText(t: number): 'white' | 'black' {
return t > 0.5 ? 'white' : 'black'
}
// ─── Helpers para el heatmap del reporte ──────────────────────────────────────
export type HeatmapMode = 'relative' | 'absolute'
export interface ActivityCostData {
expectedTotalCost: number
percentOfTotal: number
}
// Color neutro (gris claro) cuando no hay varianza significativa entre actividades.
// Evita comunicar una falsa diferenciación cuando todos los costos son similares.
export const NEUTRAL_HEATMAP_COLOR = '#cbd5e1' // slate-300
// Umbral: si el rango relativo (max-min)/max < 5%, la varianza no es significativa.
export const VARIANCE_THRESHOLD = 0.05
export function hasSignificantCostVariance(activities: ActivityCostData[]): boolean {
if (activities.length < 2) return false
const costs = activities.map((a) => a.expectedTotalCost)
const max = Math.max(...costs)
const min = Math.min(...costs)
if (max <= 0) return false
return (max - min) / max > VARIANCE_THRESHOLD
}
// Calcula t ∈ [0,1] para una actividad dada la lista completa y el modo.
// Modo relativo: normaliza sobre la actividad más costosa (t=1 = la que más pesa).
// Modo absoluto: normaliza sobre el rango min-max del proceso.
// Pre-condición: llamar solo cuando hasSignificantCostVariance = true.
export function computeActivityT(
activity: ActivityCostData,
allActivities: ActivityCostData[],
mode: HeatmapMode
): number {
if (allActivities.length === 0) return 0
if (mode === 'relative') {
const maxPercent = Math.max(...allActivities.map((a) => a.percentOfTotal), 0.001)
return Math.max(0, Math.min(1, activity.percentOfTotal / maxPercent))
}
const costs = allActivities.map((a) => a.expectedTotalCost)
const minCost = Math.min(...costs)
const maxCost = Math.max(...costs)
if (maxCost <= minCost) return 0.5
return Math.max(0, Math.min(1, (activity.expectedTotalCost - minCost) / (maxCost - minCost)))
}
// Función unificada: devuelve el color correcto considerando varianza.
// Usar en HeatmapCanvas, ActivitiesTable y los gráficos para consistencia.
export function activityColor(
activity: ActivityCostData,
allActivities: ActivityCostData[],
mode: HeatmapMode
): string {
if (!hasSignificantCostVariance(allActivities)) return NEUTRAL_HEATMAP_COLOR
return heatmapColorHex(computeActivityT(activity, allActivities, mode))
}
// Valores de los extremos para la leyenda del heatmap
export function heatmapLegendBounds(
allActivities: ActivityCostData[],
mode: HeatmapMode
): { min: number; mid: number; max: number; unit: 'currency' | 'percent' } {
if (allActivities.length === 0) return { min: 0, mid: 50, max: 100, unit: 'percent' }
if (mode === 'relative') {
const maxPercent = Math.max(...allActivities.map((a) => a.percentOfTotal))
return { min: 0, mid: maxPercent / 2, max: maxPercent, unit: 'percent' }
}
const costs = allActivities.map((a) => a.expectedTotalCost)
const minCost = Math.min(...costs)
const maxCost = Math.max(...costs)
return { min: minCost, mid: (minCost + maxCost) / 2, max: maxCost, unit: 'currency' }
}

View File

@@ -0,0 +1,115 @@
import Papa from 'papaparse'
import { formatSimulationTimestamp } from '@/lib/format'
import { buildFileName } from './slug'
import type { Process, Simulation, Resource, ActivitySimResult } from '@/domain/types'
// Monedas que usan coma como separador decimal (necesitan punto y coma en el CSV)
const COMMA_DECIMAL_CURRENCIES = new Set(['PYG', 'BRL', 'EUR', 'ARS', 'COP', 'MXN', 'CLP'])
function getDelimiter(currency: string): string {
return COMMA_DECIMAL_CURRENCIES.has(currency) ? ';' : ','
}
function formatDecimalForCsv(value: number, currency: string): string {
const formatted = value.toFixed(2)
if (COMMA_DECIMAL_CURRENCIES.has(currency)) {
return formatted.replace('.', ',')
}
return formatted
}
function buildResourcesCell(
act: ActivitySimResult,
resources: Resource[]
): string {
if (act.resourceCostBreakdown.length === 0) return ''
const resourceById = new Map(resources.map((r) => [r.id, r]))
return act.resourceCostBreakdown
.map((rb) => {
const res = resourceById.get(rb.resourceId)
if (!res) return rb.resourceName
// Calcular % de utilización desde el costo relativo
return res.name
})
.join('; ')
}
export interface CsvExportParams {
process: Process
simulation: Simulation
resources: Resource[]
}
export function generateCsvContent(params: CsvExportParams): string {
const { process, simulation, resources } = params
const { currency } = process
const result = simulation.result
const delimiter = getDelimiter(currency)
const { date } = formatSimulationTimestamp(simulation.executedAt)
// ── Filas de metadata como comentarios ──────────────────────────────────
const meta = [
`# Proceso: ${process.name}`,
`# Cliente: ${process.clientName || 'N/A'}`,
`# Fecha simulación: ${date}`,
`# Moneda: ${currency}`,
`# Costo total (${currency}): ${formatDecimalForCsv(result.totalCost, currency)}`,
`# Costo directo: ${formatDecimalForCsv(result.totalDirectCost, currency)}`,
`# Costo indirecto (overhead ${(process.overheadPercentage * 100).toFixed(0)}%): ${formatDecimalForCsv(result.totalIndirectCost, currency)}`,
'',
].join('\r\n')
// ── Headers ──────────────────────────────────────────────────────────────
const headers = [
'ID BPMN',
'Actividad',
'Tipo',
`Costo directo (${currency})`,
`Costo indirecto (${currency})`,
`Costo total (${currency})`,
'% del total',
'Tiempo (min)',
'Ejecuciones esperadas',
'Recursos asignados',
]
// ── Filas ─────────────────────────────────────────────────────────────────
const rows = result.perActivity.map((act) => [
act.bpmnElementId,
act.activityName,
act.bpmnElementId.includes('subprocess') ? 'subproceso' : 'tarea',
formatDecimalForCsv(act.expectedDirectCost, currency),
formatDecimalForCsv(act.expectedIndirectCost, currency),
formatDecimalForCsv(act.expectedTotalCost, currency),
formatDecimalForCsv(act.percentOfTotal, currency),
formatDecimalForCsv(act.executionTimeMinutes, currency),
formatDecimalForCsv(act.executionProbability * 100, currency),
buildResourcesCell(act, resources),
])
const csvBody = Papa.unparse(
{ fields: headers, data: rows },
{ delimiter, newline: '\r\n', quotes: true }
)
// BOM UTF-8 + metadata + datos
return '' + meta + csvBody
}
export function exportToCsv(params: CsvExportParams): void {
const content = generateCsvContent(params)
const fileName = buildFileName(
params.process.name,
params.process.clientName,
new Date(params.simulation.executedAt),
'csv'
)
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}

View File

@@ -0,0 +1,131 @@
import { buildFileName } from './slug'
import {
drawHeader, drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
drawAnalysisSection, drawMethodologySection, addPageNumbers, PDF,
} from './pdf-sections'
import type { Process, Simulation, Resource } from '@/domain/types'
export interface PdfExportParams {
process: Process
simulation: Simulation
resources: Resource[]
heatmapImageData: string | null // data URL de la imagen capturada del canvas
}
export async function exportToPdf(params: PdfExportParams): Promise<void> {
const { process, simulation, resources, heatmapImageData } = params
const result = simulation.result
const currency = process.currency
// Carga dinámica: jsPDF + autotable solo se descargan al hacer click
const [{ jsPDF }, { default: autoTable }] = await Promise.all([
import('jspdf'),
import('jspdf-autotable'),
])
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
// Metadata del PDF
doc.setProperties({
title: `Análisis de costos - ${process.name}`,
author: process.clientName || '',
subject: 'Simulación de proceso BPMN',
creator: 'Process Cost Platform',
keywords: 'bpmn, costos, simulación',
})
// ── PÁGINA 1: Header + KPIs + Heatmap ────────────────────────────────────
let y = drawHeader(doc as any, process, simulation.executedAt)
y = drawKpiCards(doc as any, result, currency, y)
y += 4
// Título del heatmap
doc.setFont('helvetica', 'bold')
doc.setFontSize(10)
doc.setTextColor(...PDF.slate900)
doc.text('Mapa de calor de costos', PDF.margin, y)
y += 5
y = drawHeatmapImage(doc as any, heatmapImageData, y)
y = drawHeatmapLegend(doc as any, y)
y += 4
// ── PÁGINA 2: Análisis ────────────────────────────────────────────────────
doc.addPage()
y = PDF.margin
y = drawAnalysisSection(doc as any, result, currency, y)
y += 6
// ── PÁGINA 3+: Tabla detalle (autotable maneja paginación automática) ─────
void resources // disponible para uso futuro (recursos en tabla de actividades)
const RA = 'right' as const
const tableHead = [
[
{ content: 'Actividad', styles: { cellWidth: 48 } },
{ content: `Dir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
{ content: `Indir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
{ content: `Total (${currency})`, styles: { halign: RA, cellWidth: 28 } },
{ content: '% total', styles: { halign: RA, cellWidth: 16 } },
{ content: 'Tiempo', styles: { halign: RA, cellWidth: 14 } },
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
],
]
const tableBody = result.perActivity.map((act) => [
{ content: act.activityName },
{ content: formatNum(act.expectedDirectCost), styles: { halign: RA, font: 'courier' } },
{ content: formatNum(act.expectedIndirectCost), styles: { halign: RA, font: 'courier' } },
{ content: formatNum(act.expectedTotalCost), styles: { halign: RA, font: 'courier' } },
{ content: `${act.percentOfTotal.toFixed(1)}%`, styles: { halign: RA, font: 'courier' } },
{ content: act.executionTimeMinutes > 0 ? formatTime(act.executionTimeMinutes) : '—', styles: { halign: RA } },
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA } },
])
autoTable(doc, {
head: tableHead,
body: tableBody,
startY: y,
margin: { left: PDF.margin, right: PDF.margin },
styles: { fontSize: 8, cellPadding: 2.5, overflow: 'ellipsize' },
headStyles: {
fillColor: PDF.blue,
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 8,
},
alternateRowStyles: { fillColor: PDF.slate50 },
columnStyles: { 0: { cellWidth: 48 } },
didDrawPage: () => {
// Placeholder — los footers se añaden al final con addPageNumbers
},
})
// ── PÁGINA FINAL: Metodología ────────────────────────────────────────────
doc.addPage()
drawMethodologySection(doc as any, process, PDF.margin)
// ── FOOTER en todas las páginas ───────────────────────────────────────────
addPageNumbers(doc as any, 'Process Cost Platform', simulation.executedAt)
// ── Descargar ─────────────────────────────────────────────────────────────
const fileName = buildFileName(
process.name,
process.clientName,
new Date(simulation.executedAt),
'pdf'
)
doc.save(fileName)
}
function formatNum(n: number): string {
return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function formatTime(minutes: number): string {
if (minutes < 60) return `${Math.round(minutes)}m`
const h = Math.floor(minutes / 60)
const m = Math.round(minutes % 60)
return m > 0 ? `${h}h ${m}m` : `${h}h`
}

View File

@@ -0,0 +1,291 @@
import { formatCurrency, formatMinutes, formatSimulationTimestamp } from '@/lib/format'
import type { SimulationResult, Process } from '@/domain/types'
// Unidades: mm. A4 = 210 × 297mm, márgenes 20mm, área útil 170 × 257mm
export const PDF = {
pageW: 210, pageH: 297,
margin: 20,
contentW: 170,
blue: [30, 64, 175] as [number, number, number],
slate900: [15, 23, 42] as [number, number, number],
slate700: [51, 65, 85] as [number, number, number],
slate500: [100, 116, 139] as [number, number, number],
slate200: [226, 232, 240] as [number, number, number],
slate50: [248, 250, 252] as [number, number, number],
heatLow: [16, 185, 129] as [number, number, number],
heatMid: [245, 158, 11] as [number, number, number],
heatHigh: [239, 68, 68] as [number, number, number],
}
// Tipo mínimo de doc para los helpers (evitar importar jsPDF en este archivo)
type DocLike = {
setFont: (name: string, style: string) => void
setFontSize: (size: number) => void
setTextColor: (...args: number[]) => void
setFillColor: (...args: number[]) => void
setDrawColor: (...args: number[]) => void
setLineWidth: (w: number) => void
text: (text: string | string[], x: number, y: number, opts?: Record<string, unknown>) => void
rect: (x: number, y: number, w: number, h: number, style?: string) => void
roundedRect: (x: number, y: number, w: number, h: number, rx: number, ry: number, style?: string) => void
line: (x1: number, y1: number, x2: number, y2: number) => void
addImage: (img: string, format: string, x: number, y: number, w: number, h: number) => void
addPage: () => void
setPage: (page: number) => void
internal: { getNumberOfPages: () => number }
splitTextToSize: (text: string, maxWidth: number) => string[]
}
export function drawHeader(doc: DocLike, process: Process, simulatedAt: number): number {
let y = PDF.margin
// Nombre del proceso
doc.setFont('helvetica', 'bold')
doc.setFontSize(20)
doc.setTextColor(...PDF.slate900)
doc.text(process.name, PDF.margin, y)
y += 8
// Cliente + fecha + moneda
const { date, time } = formatSimulationTimestamp(simulatedAt)
const subtitle = [
process.clientName ? `Cliente: ${process.clientName}` : null,
`Simulado el ${date} a las ${time}`,
`Moneda: ${process.currency}`,
].filter(Boolean).join(' · ')
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF.slate500)
doc.text(subtitle, PDF.margin, y)
y += 5
// Línea separadora
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.3)
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
y += 5
return y
}
export function drawKpiCards(
doc: DocLike,
result: SimulationResult,
currency: string,
startY: number
): number {
const cardW = 80, cardH = 24, gap = 10
const col1 = PDF.margin, col2 = PDF.margin + cardW + gap
const cards = [
{ label: 'COSTO TOTAL ESPERADO', value: formatCurrency(result.totalCost, currency), accent: true },
{ label: 'COSTO DIRECTO / OVERHEAD', value: `${formatCurrency(result.totalDirectCost, currency)} / ${formatCurrency(result.totalIndirectCost, currency)}`, accent: false },
{ label: 'TIEMPO TOTAL ESPERADO', value: formatMinutes(result.totalTimeMinutes), accent: false },
{ label: 'ACTIVIDADES COSTEADAS', value: String(result.perActivity.length), accent: false },
]
let maxY = startY
cards.forEach((card, idx) => {
const x = idx % 2 === 0 ? col1 : col2
const y = idx < 2 ? startY : startY + cardH + 4
// Fondo
doc.setFillColor(...PDF.slate50)
doc.roundedRect(x, y, cardW, cardH, 2, 2, 'F')
// Borde
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.2)
doc.roundedRect(x, y, cardW, cardH, 2, 2, 'S')
// Label pequeño
doc.setFont('helvetica', 'normal')
doc.setFontSize(6.5)
doc.setTextColor(...PDF.slate500)
doc.text(card.label, x + 4, y + 6)
// Valor grande
doc.setFont('helvetica', 'bold')
doc.setFontSize(card.value.length > 16 ? 9 : 12)
doc.setTextColor(card.accent ? PDF.blue[0] : PDF.slate900[0], card.accent ? PDF.blue[1] : PDF.slate900[1], card.accent ? PDF.blue[2] : PDF.slate900[2])
doc.text(card.value, x + 4, y + 16)
maxY = Math.max(maxY, y + cardH)
})
return maxY + 6
}
export function drawHeatmapImage(
doc: DocLike,
imageDataUrl: string | null,
startY: number
): number {
if (!imageDataUrl) return startY
// Calcular altura proporcional (asumiendo aspect ratio ~2:1 del canvas BPMN)
const imgW = PDF.contentW
const imgH = Math.min(imgW / 2.2, 80) // máximo 80mm de alto
const x = PDF.margin
if (startY + imgH > PDF.pageH - PDF.margin - 10) {
doc.addPage()
startY = PDF.margin
}
// Borde sutil
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.2)
doc.rect(x, startY, imgW, imgH, 'S')
doc.addImage(imageDataUrl, 'JPEG', x, startY, imgW, imgH)
return startY + imgH + 4
}
export function drawHeatmapLegend(doc: DocLike, startY: number): number {
const x = PDF.margin, barW = PDF.contentW, barH = 4
const labelY = startY + barH + 3.5
doc.setFontSize(7)
doc.setFont('helvetica', 'normal')
// Etiqueta izquierda
doc.setTextColor(...PDF.heatLow)
doc.text('Bajo costo', x, labelY)
// Barra de gradiente: dibujada como N segmentos de colores
const steps = 40
const segW = barW / steps
for (let i = 0; i < steps; i++) {
const t = i / (steps - 1)
let r: number, g: number, b: number
if (t <= 0.5) {
const lt = t * 2
r = Math.round(PDF.heatLow[0] + (PDF.heatMid[0] - PDF.heatLow[0]) * lt)
g = Math.round(PDF.heatLow[1] + (PDF.heatMid[1] - PDF.heatLow[1]) * lt)
b = Math.round(PDF.heatLow[2] + (PDF.heatMid[2] - PDF.heatLow[2]) * lt)
} else {
const lt = (t - 0.5) * 2
r = Math.round(PDF.heatMid[0] + (PDF.heatHigh[0] - PDF.heatMid[0]) * lt)
g = Math.round(PDF.heatMid[1] + (PDF.heatHigh[1] - PDF.heatMid[1]) * lt)
b = Math.round(PDF.heatMid[2] + (PDF.heatHigh[2] - PDF.heatMid[2]) * lt)
}
doc.setFillColor(r, g, b)
doc.rect(x + i * segW, startY, segW + 0.1, barH, 'F')
}
// Etiqueta derecha
doc.setTextColor(...PDF.heatHigh)
doc.text('Alto costo', x + barW, labelY, { align: 'right' } as any)
return labelY + 3
}
export function drawAnalysisSection(
doc: DocLike,
result: SimulationResult,
currency: string,
startY: number
): number {
let y = startY
doc.setFont('helvetica', 'bold')
doc.setFontSize(12)
doc.setTextColor(...PDF.slate900)
doc.text('Análisis de composición', PDF.margin, y)
y += 6
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.2)
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF.slate700)
// Composición directo/indirecto
const directPct = result.totalCost > 0
? ((result.totalDirectCost / result.totalCost) * 100).toFixed(1)
: '0'
const indirectPct = result.totalCost > 0
? ((result.totalIndirectCost / result.totalCost) * 100).toFixed(1)
: '0'
doc.text(`Composición del costo: ${directPct}% directo (${formatCurrency(result.totalDirectCost, currency)}) · ${indirectPct}% overhead (${formatCurrency(result.totalIndirectCost, currency)})`, PDF.margin, y)
y += 6
// Top 3 actividades
const top3 = result.perActivity.slice(0, 3)
const top3Text = top3.map((a, i) => `${i + 1}. ${a.activityName} (${a.percentOfTotal.toFixed(1)}% — ${formatCurrency(a.expectedTotalCost, currency)})`).join(' ')
const lines = doc.splitTextToSize(`Top 3 actividades por costo: ${top3Text}`, PDF.contentW)
doc.text(lines, PDF.margin, y)
y += lines.length * 4.5 + 2
// Recurso más costoso (si hay)
if (result.perResource.length > 0) {
const topResource = [...result.perResource].sort((a, b) => b.totalCost - a.totalCost)[0]
doc.text(`Recurso más costoso: "${topResource.resourceName}" con ${formatCurrency(topResource.totalCost, currency)} en total`, PDF.margin, y)
y += 5
} else {
doc.setTextColor(...PDF.slate500)
doc.text('Sin recursos asignados (costos son fijos por actividad)', PDF.margin, y)
doc.setTextColor(...PDF.slate700)
y += 5
}
return y + 2
}
export function drawMethodologySection(
doc: DocLike,
process: Process,
startY: number
): number {
let y = startY
doc.setFont('helvetica', 'bold')
doc.setFontSize(10)
doc.setTextColor(...PDF.slate900)
doc.text('Nota metodológica', PDF.margin, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.setTextColor(...PDF.slate500)
const lines = [
`Costo total = Σ(actividades) costo_directo_esperado + costo_indirecto_esperado.`,
`Costo directo por actividad = (costo_fijo + Σ(recurso × costo_hora × tiempo × utilización / 60)) × probabilidad_ejecución.`,
`Costo indirecto = costo_directo_total × overhead_global (${(process.overheadPercentage * 100).toFixed(0)}%).`,
`Probabilidades de ejecución calculadas mediante propagación hacia adelante desde el StartEvent,`,
`considerando gateways XOR y AND configurados. Modelo determinístico agregado — MVP Fase 0.`,
]
for (const line of lines) {
const wrapped = doc.splitTextToSize(line, PDF.contentW)
doc.text(wrapped, PDF.margin, y)
y += wrapped.length * 4
}
return y
}
export function addPageNumbers(doc: DocLike, platformName: string, generatedAt: number): void {
const total = doc.internal.getNumberOfPages()
const { date } = formatSimulationTimestamp(generatedAt)
for (let i = 1; i <= total; i++) {
doc.setPage(i)
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
doc.setTextColor(180, 180, 180)
doc.text(
`${platformName} · ${date} · Página ${i} de ${total}`,
105,
PDF.pageH - 8,
{ align: 'center' } as any
)
}
}

28
src/lib/export/slug.ts Normal file
View File

@@ -0,0 +1,28 @@
export function slugify(text: string): string {
return text
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // eliminar diacríticos (á→a, ñ→n, ü→u)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // todo lo no-alfanumérico → guión
.replace(/^-+|-+$/g, '') // recortar guiones del inicio/fin
.slice(0, 60) // limitar longitud para nombres de archivo
}
export function buildFileName(
processName: string,
clientName: string | undefined,
date: Date,
ext: 'pdf' | 'csv'
): string {
const dateStr = [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('')
const parts = [slugify(processName)]
if (clientName?.trim()) parts.push(slugify(clientName.trim()))
parts.push(dateStr)
return `${parts.join('_')}.${ext}`
}

36
src/lib/format.ts Normal file
View File

@@ -0,0 +1,36 @@
export function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('es-PY', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}
export function formatNumber(n: number, decimals = 1): string {
return new Intl.NumberFormat('es-PY', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(n)
}
export function formatPercent(n: number, decimals = 1): string {
return `${formatNumber(n, decimals)}%`
}
export function formatMinutes(minutes: number): string {
if (minutes < 60) return `${Math.round(minutes)} min`
const h = Math.floor(minutes / 60)
const m = Math.round(minutes % 60)
return m > 0 ? `${h}h ${m}min` : `${h}h`
}
// Formato de timestamp de simulación: "13 de mayo de 2026 a las 09:45"
// Locale explícito es-PY — nunca depende del locale del browser del cliente.
export function formatSimulationTimestamp(ts: number): { date: string; time: string } {
const d = new Date(ts)
return {
date: d.toLocaleDateString('es-PY', { day: '2-digit', month: 'long', year: 'numeric' }),
time: d.toLocaleTimeString('es-PY', { hour: '2-digit', minute: '2-digit' }),
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@/styles/globals.css'
import { App } from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

24
src/persistence/db.ts Normal file
View File

@@ -0,0 +1,24 @@
import Dexie, { type Table } from 'dexie'
import type { Process, Activity, GatewayConfig, Resource, Simulation } from '@/domain/types'
export class ProcessCostDb extends Dexie {
processes!: Table<Process>
activities!: Table<Activity>
gateways!: Table<GatewayConfig>
resources!: Table<Resource>
simulations!: Table<Simulation>
constructor() {
super('ProcessCostPlatform')
this.version(1).stores({
processes: 'id, name, updatedAt',
activities: 'id, processId, bpmnElementId',
gateways: 'id, processId, bpmnElementId',
resources: 'id, processId, name',
simulations: 'id, processId, executedAt',
})
}
}
export const db = new ProcessCostDb()

View File

@@ -0,0 +1,114 @@
import { db } from './db'
import type { Process, Activity, GatewayConfig, Resource, Simulation } from '@/domain/types'
// ─── Process ─────────────────────────────────────────────────────────────────
export const processRepo = {
async save(process: Process): Promise<void> {
await db.processes.put(process)
},
async getById(id: string): Promise<Process | undefined> {
return db.processes.get(id)
},
async getAll(): Promise<Process[]> {
return db.processes.orderBy('updatedAt').reverse().toArray()
},
async delete(id: string): Promise<void> {
await db.transaction('rw', [db.processes, db.activities, db.gateways, db.resources, db.simulations], async () => {
await db.processes.delete(id)
await db.activities.where('processId').equals(id).delete()
await db.gateways.where('processId').equals(id).delete()
await db.resources.where('processId').equals(id).delete()
await db.simulations.where('processId').equals(id).delete()
})
},
}
// ─── Activity ─────────────────────────────────────────────────────────────────
export const activityRepo = {
async save(activity: Activity): Promise<void> {
await db.activities.put(activity)
},
async saveMany(activities: Activity[]): Promise<void> {
await db.activities.bulkPut(activities)
},
async getByProcess(processId: string): Promise<Activity[]> {
return db.activities.where('processId').equals(processId).toArray()
},
async getByBpmnElementId(processId: string, bpmnElementId: string): Promise<Activity | undefined> {
return db.activities
.where('[processId+bpmnElementId]')
.equals([processId, bpmnElementId])
.first()
},
async deleteByProcess(processId: string): Promise<void> {
await db.activities.where('processId').equals(processId).delete()
},
}
// ─── GatewayConfig ────────────────────────────────────────────────────────────
export const gatewayRepo = {
async save(gateway: GatewayConfig): Promise<void> {
await db.gateways.put(gateway)
},
async saveMany(gateways: GatewayConfig[]): Promise<void> {
await db.gateways.bulkPut(gateways)
},
async getByProcess(processId: string): Promise<GatewayConfig[]> {
return db.gateways.where('processId').equals(processId).toArray()
},
async deleteByProcess(processId: string): Promise<void> {
await db.gateways.where('processId').equals(processId).delete()
},
}
// ─── Resource ─────────────────────────────────────────────────────────────────
export const resourceRepo = {
async save(resource: Resource): Promise<void> {
await db.resources.put(resource)
},
async getByProcess(processId: string): Promise<Resource[]> {
return db.resources.where('processId').equals(processId).toArray()
},
async delete(id: string): Promise<void> {
await db.resources.delete(id)
},
async deleteByProcess(processId: string): Promise<void> {
await db.resources.where('processId').equals(processId).delete()
},
}
// ─── Simulation ───────────────────────────────────────────────────────────────
export const simulationRepo = {
async save(simulation: Simulation): Promise<void> {
await db.simulations.put(simulation)
},
async getLatestByProcess(processId: string): Promise<Simulation | undefined> {
return db.simulations
.where('processId')
.equals(processId)
.last()
},
async getByProcess(processId: string): Promise<Simulation[]> {
return db.simulations.where('processId').equals(processId).toArray()
},
}

51
src/router.tsx Normal file
View File

@@ -0,0 +1,51 @@
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 { Loader2 } from 'lucide-react'
// ECharts es grande (373KB gzip). Lazy-load del ReportPage para que el
// bundle inicial no lo incluya — se carga solo cuando el usuario llega al reporte.
const ReportPage = lazy(() =>
import('@/features/report/ReportPage').then((m) => ({ default: m.ReportPage }))
)
function ReportPageWithSuspense() {
return (
<Suspense fallback={
<div className="min-h-screen flex flex-col items-center justify-center gap-3 bg-slate-50">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-slate-400">Cargando reporte</p>
</div>
}>
<ReportPage />
</Suspense>
)
}
const rootRoute = createRootRoute({ component: () => <Outlet /> })
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: ImportPage,
})
const workspaceRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/workspace/$processId',
component: WorkspacePage,
})
const reportRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/report/$processId',
component: ReportPageWithSuspense,
})
const routeTree = rootRoute.addChildren([indexRoute, workspaceRoute, reportRoute])
export const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register { router: typeof router }
}

View File

@@ -0,0 +1,99 @@
import { create } from 'zustand'
import { processRepo, activityRepo, gatewayRepo, resourceRepo } from '@/persistence/repositories'
import type { Process, Activity, GatewayConfig, Resource } from '@/domain/types'
interface ProcessState {
currentProcess: Process | null
activities: Activity[]
gateways: GatewayConfig[]
resources: Resource[]
isLoading: boolean
error: string | null
// Acciones
loadProcess: (processId: string) => Promise<void>
setCurrentProcess: (process: Process) => Promise<void>
updateProcess: (updates: Partial<Process>) => Promise<void>
updateActivity: (activity: Activity) => Promise<void>
updateGateway: (gateway: GatewayConfig) => Promise<void>
addResource: (resource: Resource) => Promise<void>
updateResource: (resource: Resource) => Promise<void>
deleteResource: (resourceId: string) => Promise<void>
reset: () => void
}
export const useProcessStore = create<ProcessState>((set, get) => ({
currentProcess: null,
activities: [],
gateways: [],
resources: [],
isLoading: false,
error: null,
loadProcess: async (processId) => {
set({ isLoading: true, error: null })
try {
const [process, activities, gateways, resources] = await Promise.all([
processRepo.getById(processId),
activityRepo.getByProcess(processId),
gatewayRepo.getByProcess(processId),
resourceRepo.getByProcess(processId),
])
if (!process) throw new Error('Proceso no encontrado')
set({ currentProcess: process, activities, gateways, resources, isLoading: false })
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Error desconocido', isLoading: false })
}
},
setCurrentProcess: async (process) => {
await processRepo.save(process)
const [activities, gateways, resources] = await Promise.all([
activityRepo.getByProcess(process.id),
gatewayRepo.getByProcess(process.id),
resourceRepo.getByProcess(process.id),
])
set({ currentProcess: process, activities, gateways, resources })
},
updateProcess: async (updates) => {
const current = get().currentProcess
if (!current) return
const updated: Process = { ...current, ...updates, updatedAt: Date.now() }
await processRepo.save(updated)
set({ currentProcess: updated })
},
updateActivity: async (activity) => {
await activityRepo.save(activity)
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)),
}))
},
addResource: async (resource) => {
await resourceRepo.save(resource)
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)),
}))
},
deleteResource: async (resourceId) => {
await resourceRepo.delete(resourceId)
set((state) => ({ resources: state.resources.filter((r) => r.id !== resourceId) }))
},
reset: () => set({ currentProcess: null, activities: [], gateways: [], resources: [], error: null }),
}))

View File

@@ -0,0 +1,28 @@
import { create } from 'zustand'
import type { SimulationResult } from '@/domain/types'
interface SimulationState {
result: SimulationResult | null
isRunning: boolean
error: string | null
selectedActivityId: string | null
setResult: (result: SimulationResult) => void
setRunning: (running: boolean) => void
setError: (error: string | null) => void
selectActivity: (bpmnElementId: string | null) => void
reset: () => void
}
export const useSimulationStore = create<SimulationState>((set) => ({
result: null,
isRunning: false,
error: null,
selectedActivityId: null,
setResult: (result) => set({ result, error: null }),
setRunning: (isRunning) => set({ isRunning }),
setError: (error) => set({ error }),
selectActivity: (selectedActivityId) => set({ selectedActivityId }),
reset: () => set({ result: null, isRunning: false, error: null, selectedActivityId: null }),
}))

92
src/styles/globals.css Normal file
View File

@@ -0,0 +1,92 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@import 'bpmn-js/dist/assets/diagram-js.css';
@import 'bpmn-js/dist/assets/bpmn-js.css';
@import 'bpmn-js/dist/assets/bpmn-font/css/bpmn.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans;
font-feature-settings: "rlig" 1, "calt" 1;
}
.font-mono {
font-family: 'JetBrains Mono', monospace;
}
}
/* ── bpmn-js canvas ─────────────────────────────────────────── */
.bpmn-container .djs-container {
background: #f8fafc; /* slate-50 */
}
/* Elemento seleccionado desde nuestro código */
.bpmn-container .bpmn-selected .djs-visual > :first-child {
stroke: hsl(var(--primary)) !important;
stroke-width: 2.5px !important;
}
/* Elemento clickeable: cursor pointer en tasks */
.bpmn-container .djs-element[data-element-id] {
cursor: default;
}
/* Hover visual en tasks */
.bpmn-container .djs-shape:hover .djs-visual > rect,
.bpmn-container .djs-shape:hover .djs-visual > path {
filter: brightness(0.97);
}
/* heatmap overlays — para la Etapa 5 */
.heatmap-overlay {
pointer-events: none;
transition: fill 0.3s ease;
}
/* Asegurar que bpmn-js ocupa todo su contenedor */
.bpmn-container .djs-container svg {
width: 100% !important;
height: 100% !important;
}
/* Cursor pointer sobre tasks en el heatmap del reporte */
.bpmn-container .djs-shape[data-element-id] .djs-hit {
cursor: default;
}
/* Selección en el heatmap del reporte — stroke más prominente */
.bpmn-container .bpmn-selected .djs-visual > rect {
stroke: hsl(var(--primary)) !important;
stroke-width: 3px !important;
stroke-dasharray: none !important;
}

62
tailwind.config.js Normal file
View File

@@ -0,0 +1,62 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: [
'./index.html',
'./src/**/*.{ts,tsx}',
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
heatmap: {
low: '#10b981',
mid: '#f59e0b',
high: '#ef4444',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,210 @@
import { describe, it, expect } from 'vitest'
import { parseBpmnXml, extractActivityElements, extractGatewayElements } from '@/domain/bpmn-parser'
import { runSimulation } from '@/domain/simulation'
import type { Activity, Resource, GatewayConfig, SimulationInput } from '@/domain/types'
// BPMN mínimo para tests — proceso lineal de 2 tareas
const LINEAR_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>
<task id="taskB" name="Tarea B"><incoming>f2</incoming><outgoing>f3</outgoing></task>
<endEvent id="end"><incoming>f3</incoming></endEvent>
<sequenceFlow id="f1" sourceRef="start" targetRef="taskA"/>
<sequenceFlow id="f2" sourceRef="taskA" targetRef="taskB"/>
<sequenceFlow id="f3" sourceRef="taskB" targetRef="end"/>
</process>
</definitions>`
// BPMN con gateway XOR
const XOR_BPMN = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
<process id="proc2" isExecutable="false">
<startEvent id="start"><outgoing>f1</outgoing></startEvent>
<task id="taskA" name="Evaluar"><incoming>f1</incoming><outgoing>f2</outgoing></task>
<exclusiveGateway id="gw1" name="Decision"><incoming>f2</incoming><outgoing>f3</outgoing><outgoing>f4</outgoing></exclusiveGateway>
<task id="taskB" name="Camino Si"><incoming>f3</incoming><outgoing>f5</outgoing></task>
<task id="taskC" name="Camino No"><incoming>f4</incoming><outgoing>f6</outgoing></task>
<endEvent id="end"><incoming>f5</incoming><incoming>f6</incoming></endEvent>
<sequenceFlow id="f1" sourceRef="start" targetRef="taskA"/>
<sequenceFlow id="f2" sourceRef="taskA" targetRef="gw1"/>
<sequenceFlow id="f3" sourceRef="gw1" targetRef="taskB"/>
<sequenceFlow id="f4" sourceRef="gw1" targetRef="taskC"/>
<sequenceFlow id="f5" sourceRef="taskB" targetRef="end"/>
<sequenceFlow id="f6" sourceRef="taskC" targetRef="end"/>
</process>
</definitions>`
describe('parseBpmnXml', () => {
it('parsea proceso lineal y extrae nodos y flujos', () => {
const graph = parseBpmnXml(LINEAR_BPMN)
expect(graph.nodes.size).toBe(4) // start, taskA, taskB, end
expect(graph.flows.size).toBe(3)
expect(graph.startEventIds).toEqual(['start'])
})
it('extrae actividades correctamente', () => {
const graph = parseBpmnXml(LINEAR_BPMN)
const activities = extractActivityElements(graph)
expect(activities).toHaveLength(2)
expect(activities[0].bpmnElementId).toBe('taskA')
expect(activities[1].bpmnElementId).toBe('taskB')
})
it('extrae gateways divergentes', () => {
const graph = parseBpmnXml(XOR_BPMN)
const gateways = extractGatewayElements(graph)
expect(gateways).toHaveLength(1)
expect(gateways[0].bpmnElementId).toBe('gw1')
expect(gateways[0].outgoing).toHaveLength(2)
})
it('lanza error si el XML no tiene proceso', () => {
expect(() => parseBpmnXml('<bad/>')).toThrow()
})
})
describe('runSimulation', () => {
const makeResource = (id: string, name: string, costPerHour: number): Resource => ({
id,
processId: 'proc1',
name,
type: 'role',
costPerHour,
})
const makeActivity = (
bpmnElementId: string,
name: string,
directCost: number,
minutes: number,
resources: Array<{ resourceId: string; utilizationPercent: number }> = []
): Activity => ({
id: `act-${bpmnElementId}`,
processId: 'proc1',
bpmnElementId,
name,
type: 'task',
directCostFixed: directCost,
executionTimeMinutes: minutes,
assignedResources: resources,
})
it('calcula costo total sin recursos — proceso lineal', () => {
const activityA = makeActivity('taskA', 'Tarea A', 100, 30)
const activityB = makeActivity('taskB', 'Tarea B', 200, 60)
const input: SimulationInput = {
processXml: LINEAR_BPMN,
activities: new Map([
['taskA', activityA],
['taskB', activityB],
]),
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
}
const result = runSimulation(input)
expect(result.totalDirectCost).toBe(300)
expect(result.totalIndirectCost).toBe(0)
expect(result.totalCost).toBe(300)
expect(result.warnings).toHaveLength(0)
})
it('aplica overhead correctamente', () => {
const activityA = makeActivity('taskA', 'Tarea A', 100, 30)
const input: SimulationInput = {
processXml: LINEAR_BPMN,
activities: new Map([['taskA', activityA]]),
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
}
const result = runSimulation(input)
expect(result.totalDirectCost).toBe(100)
expect(result.totalIndirectCost).toBeCloseTo(20)
expect(result.totalCost).toBeCloseTo(120)
})
it('incluye costo de recursos asignados', () => {
const resource = makeResource('res1', 'Analista', 60) // $60/hora
const activityA = makeActivity('taskA', 'Tarea A', 0, 60, [
{ resourceId: 'res1', utilizationPercent: 1.0 }, // 1 hora completa
])
const input: SimulationInput = {
processXml: LINEAR_BPMN,
activities: new Map([['taskA', activityA]]),
gateways: new Map(),
resources: new Map([['res1', resource]]),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
}
const result = runSimulation(input)
expect(result.totalDirectCost).toBeCloseTo(60)
})
it('propaga probabilidad 0.7/0.3 en gateway XOR', () => {
const actA = makeActivity('taskA', 'Evaluar', 100, 30)
const actB = makeActivity('taskB', 'Camino Si', 200, 60)
const actC = makeActivity('taskC', 'Camino No', 100, 30)
const gw: GatewayConfig = {
id: 'gw-1',
processId: 'proc2',
bpmnElementId: 'gw1',
gatewayType: 'exclusive',
branches: [
{ flowId: 'f3', targetElementId: 'taskB', probability: 0.7 },
{ flowId: 'f4', targetElementId: 'taskC', probability: 0.3 },
],
}
const input: SimulationInput = {
processXml: XOR_BPMN,
activities: new Map([
['taskA', actA],
['taskB', actB],
['taskC', actC],
]),
gateways: new Map([['gw1', gw]]),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
}
const result = runSimulation(input)
const bResult = result.perActivity.find((a) => a.bpmnElementId === 'taskB')
const cResult = result.perActivity.find((a) => a.bpmnElementId === 'taskC')
expect(bResult?.executionProbability).toBeCloseTo(0.7, 1)
expect(cResult?.executionProbability).toBeCloseTo(0.3, 1)
// Costo esperado de B = 200 * 0.7 = 140
expect(bResult?.expectedDirectCost).toBeCloseTo(140)
// Costo esperado de C = 100 * 0.3 = 30
expect(cResult?.expectedDirectCost).toBeCloseTo(30)
})
it('ordena perActivity por costo total descendente', () => {
const actA = makeActivity('taskA', 'Tarea A', 50, 10)
const actB = makeActivity('taskB', 'Tarea B', 500, 60)
const input: SimulationInput = {
processXml: LINEAR_BPMN,
activities: new Map([
['taskA', actA],
['taskB', actB],
]),
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
}
const result = runSimulation(input)
expect(result.perActivity[0].bpmnElementId).toBe('taskB')
expect(result.perActivity[1].bpmnElementId).toBe('taskA')
})
})

View File

@@ -0,0 +1,418 @@
/**
* E2E: Verifica que el export PDF y CSV funcionen de extremo a extremo.
*
* Por cada BPMN: importa → simula → descarga PDF y CSV → verifica tamaño y contenido.
* El test de acentos verifica que los caracteres UTF-8 se preserven en el PDF.
*
* Ejecutar: npx playwright test
*/
import { test, expect } from '@playwright/test'
import { readFileSync, mkdirSync } from 'fs'
import { resolve } from 'path'
import { fileURLToPath } from 'url'
import { PDFParse } from 'pdf-parse'
import sharp from 'sharp'
import { PDFDocument, PDFName, PDFRawStream } from 'pdf-lib'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const BPMN_DIR = resolve(__dirname, '../../public/sample-processes')
const OUTPUT_DIR = resolve(__dirname, '__output__')
test.beforeAll(() => {
mkdirSync(OUTPUT_DIR, { recursive: true })
})
// ─── Helper: parsea texto de un buffer PDF con pdf-parse v2 ──────────────────
async function parsePdf(buffer: Buffer): Promise<{ numpages: number; text: string }> {
const parser = new PDFParse({ data: buffer })
const result = await parser.getText()
await parser.destroy()
return { numpages: result.total, text: result.text }
}
// ─── Helper: importa un BPMN, simula y llega al reporte ──────────────────────
async function importAndSimulate(page: import('@playwright/test').Page, bpmnFile: string) {
await page.goto('/')
// Subir el BPMN vía el input oculto (equivalente a drag-and-drop programático)
const fileInput = page.locator('#bpmn-file-input')
await fileInput.setInputFiles(resolve(BPMN_DIR, bpmnFile))
// Esperar navegación al workspace
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
// Esperar a que desaparezca el spinner de carga (workspace renderiza el botón Simular)
await page.waitForSelector('button:has-text("Simular")', { timeout: 15_000 })
// Tiempo adicional para que Dexie cargue gateways y actividades en la store
await page.waitForTimeout(1_500)
// Verificar que el botón no está deshabilitado y hacer clic
const simularBtn = page.getByRole('button', { name: 'Simular' })
await expect(simularBtn).not.toBeDisabled({ timeout: 5_000 })
await simularBtn.click()
// Esperar navegación al reporte
await page.waitForURL(/\/report\//, { timeout: 20_000 })
// Esperar a que el reporte cargue y el botón de export sea visible
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
// Esperar a que bpmn-js en HeatmapCanvas haya renderizado el diagrama.
// .djs-group es creado por bpmn-js después de importXML() — confirma render real.
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
}
// ─── Casos por BPMN ──────────────────────────────────────────────────────────
const CASES = [
{
file: 'simple-linear.bpmn',
slug: 'simple-linear',
processNameFragment: 'simple-linear',
currency: 'USD',
},
{
file: 'medium-with-gateways.bpmn',
slug: 'medium-with-gateways',
processNameFragment: 'medium-with-gateways',
currency: 'USD',
},
{
file: 'complex-with-loop.bpmn',
slug: 'complex-with-loop',
processNameFragment: 'complex-with-loop',
currency: 'USD',
},
]
for (const c of CASES) {
test(`Export PDF + CSV — ${c.file}`, async ({ page }) => {
await importAndSimulate(page, c.file)
// ── PDF ─────────────────────────────────────────────────────────────────
const pdfPath = resolve(OUTPUT_DIR, `${c.slug}.pdf`)
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dl = await pdfDownload
await dl.saveAs(pdfPath)
// Esperar a que el estado "Generando…" desaparezca
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
// Verificar tamaño
const pdfBuffer = readFileSync(pdfPath)
const pdfBytes = pdfBuffer.length
console.log(`\n📄 ${c.file} — PDF: ${pdfBytes} bytes (${(pdfBytes / 1024).toFixed(1)} KB)`)
// PDF mínimo: debe tener al menos algo de contenido
expect(pdfBytes, `PDF de ${c.file} debe ser > 40 KB`).toBeGreaterThan(40_000)
expect(pdfBytes, `PDF de ${c.file} debe ser < 2 MB`).toBeLessThan(2_000_000)
// El heatmap JPEG debe estar embebido en el PDF (marker SOI: FF D8 FF).
// Sin actividades con costos reales el JPEG es pequeño (~32 KB, color neutro #cbd5e1)
// pero SIEMPRE debe estar presente cuando el canvas se renderizó correctamente.
const jpegMarker = Buffer.from([0xff, 0xd8, 0xff])
const hasJpeg = pdfBuffer.includes(jpegMarker)
console.log(` Heatmap JPEG embebido: ${hasJpeg}`)
expect(hasJpeg, 'PDF generado sin heatmap embebido — la race condition de captureImage no fue resuelta').toBe(true)
// Verificar contenido
const { numpages, text } = await parsePdf(pdfBuffer)
console.log(` Páginas: ${numpages}`)
console.log(` Texto (primeros 200 chars): ${text.slice(0, 200).replace(/\n/g, ' ')}`)
expect(numpages, 'PDF debe tener al menos 2 páginas').toBeGreaterThanOrEqual(2)
// El KPI de costo aparece en mayúsculas en el PDF: "COSTO TOTAL ESPERADO"
expect(text, 'PDF debe contener "COSTO TOTAL"').toContain('COSTO TOTAL')
expect(text, `PDF debe contener la moneda "${c.currency}"`).toContain(c.currency)
expect(text, 'PDF debe contener el nombre del proceso').toContain(c.processNameFragment)
// ── CSV ─────────────────────────────────────────────────────────────────
const csvPath = resolve(OUTPUT_DIR, `${c.slug}.csv`)
const csvDownload = page.waitForEvent('download')
await page.getByRole('button', { name: 'Exportar CSV' }).click()
const dlCsv = await csvDownload
await dlCsv.saveAs(csvPath)
const csvBuffer = readFileSync(csvPath)
const csvBytes = csvBuffer.length
console.log(` CSV: ${csvBytes} bytes`)
expect(csvBytes, `CSV de ${c.file} debe ser > 100 bytes`).toBeGreaterThan(100)
const csvText = csvBuffer.toString('utf-8')
expect(csvText, 'CSV debe tener BOM UTF-8').toMatch(/^/)
expect(csvText, 'CSV debe contener "Costo total"').toContain('Costo total')
expect(csvText, 'CSV debe contener la moneda').toContain(c.currency)
})
}
// ─── Test especial: acentos y ñ ───────────────────────────────────────────────
test('Export PDF — acentos y ñ se preservan en el PDF (accent-test.bpmn)', async ({ page }) => {
await importAndSimulate(page, 'accent-test.bpmn')
const pdfPath = resolve(OUTPUT_DIR, 'accent-test.pdf')
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dl = await pdfDownload
await dl.saveAs(pdfPath)
const pdfBuffer = readFileSync(pdfPath)
const pdfBytes = pdfBuffer.length
console.log(`\n🔤 accent-test.bpmn — PDF: ${pdfBytes} bytes (${(pdfBytes / 1024).toFixed(1)} KB)`)
expect(pdfBytes, 'PDF debe ser > 40 KB').toBeGreaterThan(40_000)
const jpegMarker = Buffer.from([0xff, 0xd8, 0xff])
const hasJpeg = pdfBuffer.includes(jpegMarker)
console.log(` Heatmap JPEG embebido: ${hasJpeg}`)
expect(hasJpeg, 'PDF generado sin heatmap embebido — la race condition de captureImage no fue resuelta').toBe(true)
const { numpages, text } = await parsePdf(pdfBuffer)
console.log(` Páginas: ${numpages}`)
console.log(` Texto completo:\n${text}`)
expect(numpages).toBeGreaterThanOrEqual(2)
// Verificar que los caracteres acentuados se preservan
expect(text, 'PDF debe contener "Análisis de procesos contables"')
.toContain('Análisis de procesos contables')
expect(text, 'PDF debe contener "Validación de información"')
.toContain('Validación de información')
expect(text, 'PDF debe contener "Inspección visual y funcional"')
.toContain('Inspección visual y funcional')
expect(text, 'PDF debe contener "Diseño de campañas"')
.toContain('Diseño de campañas')
// CSV del accent-test
await page.waitForSelector('button:has-text("Exportar CSV")', { timeout: 10_000 })
const csvPath = resolve(OUTPUT_DIR, 'accent-test.csv')
const csvDownload = page.waitForEvent('download')
await page.getByRole('button', { name: 'Exportar CSV' }).click()
const dlCsv = await csvDownload
await dlCsv.saveAs(csvPath)
const csvText = readFileSync(csvPath, 'utf-8')
console.log(` CSV (primeros 400 chars): ${csvText.slice(0, 400)}`)
expect(csvText).toContain('Análisis de procesos contables')
expect(csvText).toContain('Validación de información')
expect(csvText).toContain('Inspección visual y funcional')
expect(csvText).toContain('Diseño de campañas')
})
// ─── Helpers para extracción y análisis de JPEG ───────────────────────────────
/**
* Extrae el primer stream JPEG (DCTDecode) del PDF.
* Intenta con pdf-lib; si falla, usa búsqueda binaria de marcador SOI/EOI.
*/
async function extractJpegFromPdf(pdfBuf: Buffer): Promise<Buffer> {
// Intento 1: pdf-lib (semántico, correcto)
try {
const doc = await PDFDocument.load(pdfBuf, { ignoreEncryption: true })
for (const [, obj] of doc.context.enumerateIndirectObjects()) {
if (obj instanceof PDFRawStream) {
const subtype = obj.dict.lookupMaybe(PDFName.of('Subtype'), PDFName)
const filter = obj.dict.lookupMaybe(PDFName.of('Filter'), PDFName)
if (subtype?.toString() === '/Image' && filter?.toString() === '/DCTDecode') {
return Buffer.from(obj.contents)
}
}
}
} catch {
// pdf-lib no pudo parsear — caer a método binario
}
// Intento 2: búsqueda binaria de marcador JPEG SOI (FF D8 FF)
const soi = Buffer.from([0xff, 0xd8, 0xff])
const start = pdfBuf.indexOf(soi)
if (start === -1) throw new Error('No se encontró ningún JPEG en el PDF')
const endstreamBuf = Buffer.from('endstream')
const endstreamPos = pdfBuf.indexOf(endstreamBuf, start)
if (endstreamPos === -1) throw new Error('No se encontró "endstream" después del JPEG')
// Buscar EOI (FF D9) retrocediendo desde 'endstream'
let eoi = endstreamPos - 1
while (eoi > start + 2) {
if (pdfBuf[eoi] === 0xd9 && pdfBuf[eoi - 1] === 0xff) { eoi++; break }
eoi--
}
const jpeg = pdfBuf.subarray(start, eoi)
if (jpeg.length < 100) throw new Error(`JPEG extraído muy pequeño: ${jpeg.length} bytes`)
return jpeg
}
/**
* Cuenta píxeles por categoría de color en un buffer JPEG.
* Umbrales calibrados para los colores del heatmap con opacity 0.82 sobre #f8fafc.
*
* Verde (#10b981 + bg) → RGB ≈ (58, 197, 152) → G > R·1.4, G > 120
* Ámbar (#f59e0b + bg) → RGB ≈ (246, 175, 54) → R > 180, G > 120, B < 120, R/G < 1.8
* Rojo (#ef4444 + bg) → RGB ≈ (241, 101, 101) → R > G·1.8, R > B·1.8, R > 180
*/
async function analyzeJpegColors(jpegBuf: Buffer): Promise<{
greenPx: number; amberPx: number; redPx: number; total: number
}> {
const { data, info } = await sharp(jpegBuf).raw().toBuffer({ resolveWithObject: true })
const ch = info.channels // 3 para JPEG (RGB)
let greenPx = 0, amberPx = 0, redPx = 0
for (let i = 0; i < data.length; i += ch) {
const r = data[i], g = data[i + 1], b = data[i + 2]
if (g > r * 1.4 && g > 120 && g > b) {
greenPx++ // Verde
} else if (r > 180 && g > 120 && b < 120 && r > g && r < g * 1.8) {
amberPx++ // Ámbar / amarillo cálido
} else if (r > g * 1.8 && r > b * 1.8 && r > 180) {
redPx++ // Rojo
}
}
return { greenPx, amberPx, redPx, total: data.length / ch }
}
// ─── Test: gradiente real con costos dispares ─────────────────────────────────
test('Heatmap con gradiente real — medium-with-gateways.bpmn', async ({ page }) => {
// Costos dispares por bpmnElementId para forzar varianza significativa.
// Probabilidades de ejecución del proceso: task_recv/valid/analisis/archivo = 1.0,
// los demás ≈ 0.5. Se asignan costos para que:
// task_recv → expectedCost $10.000 → costo más alto → ROJO
// task_analisis → expectedCost $5.000 → mitad del max → ÁMBAR (t ≈ 0.5)
// resto → expectedCost < $500 → costos bajos → VERDE
const COST_BY_ELEMENT: Record<string, number> = {
task_recv: 10_000, // prob 1.0 → expected $10.000 → ROJO
task_valid: 200, // prob 1.0 → expected $200
task_score: 200, // prob 0.5 → expected $100
task_pedir_docs: 200, // prob 0.5 → expected $100
task_bureau: 200, // prob 0.5 → expected $100
task_empleador: 200, // prob 0.5 → expected $100
task_analisis: 5_000, // prob 1.0 → expected $5.000 → ÁMBAR
task_aprobar: 200, // prob 0.5 → expected $100
task_rechazar: 200, // prob 0.5 → expected $100
task_desembolso: 200, // prob 0.5 → expected $100
task_archivo: 200, // prob 1.0 → expected $200 → VERDE (mínimo)
}
// ── 1. Importar BPMN y navegar al workspace ─────────────────────────────────
await page.goto('/')
await page.locator('#bpmn-file-input').setInputFiles(resolve(BPMN_DIR, 'medium-with-gateways.bpmn'))
await page.waitForURL(/\/workspace\//)
const processId = page.url().split('/workspace/')[1]
// ── 2. Inyectar costos dispares directamente en IndexedDB (Dexie) ───────────
await page.evaluate(async ({ procId, costs }) => {
const DB_NAME = 'ProcessCostPlatform'
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
const activities = await new Promise<any[]>((resolve, reject) => {
const tx = db.transaction('activities', 'readonly')
const index = tx.objectStore('activities').index('processId')
const req = index.getAll(procId)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('activities', 'readwrite')
const store = tx.objectStore('activities')
let pending = activities.length
if (pending === 0) { resolve(); return }
for (const act of activities) {
const req = store.put({
...act,
directCostFixed: (costs as Record<string, number>)[act.bpmnElementId] ?? 200,
executionTimeMinutes: 60,
})
req.onsuccess = () => { if (--pending === 0) resolve() }
req.onerror = () => reject(req.error)
}
})
db.close()
}, { procId: processId, costs: COST_BY_ELEMENT })
// ── 3. Recargar workspace para que Zustand relea desde Dexie ────────────────
await page.reload()
await page.waitForSelector('button:has-text("Simular")', { timeout: 15_000 })
await page.waitForTimeout(1_500) // esperar carga de store
// ── 4. Simular y navegar al reporte ────────────────────────────────────────
const simularBtn = page.getByRole('button', { name: 'Simular' })
await expect(simularBtn).not.toBeDisabled({ timeout: 5_000 })
await simularBtn.click()
await page.waitForURL(/\/report\//, { timeout: 20_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 20_000 })
// ── 5. Esperar a que el heatmap tenga colores reales (no neutro #cbd5e1) ────
// Usamos style.fill (inline CSS) porque applyHeatmapToViewer ahora lo usa
// en vez de setAttribute (para garantizar mayor especificidad que bpmn-js CSS).
await page.waitForFunction(() => {
const rects = Array.from(document.querySelectorAll('.bpmn-container .djs-visual rect'))
return rects.some((r) => {
const fill = (r as HTMLElement).style.fill
return fill && fill !== '#cbd5e1'
})
}, { timeout: 30_000 })
// Capturar estado de los fills para diagnóstico
const fillsSnapshot = await page.evaluate(() =>
Array.from(document.querySelectorAll('.bpmn-container .djs-visual rect'))
.map((r) => (r as HTMLElement).style.fill || r.getAttribute('fill'))
)
console.log('\n🎨 Fills del heatmap:', fillsSnapshot)
// Screenshot para evidencia visual
await page.screenshot({ path: resolve(OUTPUT_DIR, 'medium-with-gradient-report.png'), fullPage: false })
// ── 6. Exportar PDF ─────────────────────────────────────────────────────────
const pdfPath = resolve(OUTPUT_DIR, 'medium-with-gradient.pdf')
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dl = await pdfDownload
await dl.saveAs(pdfPath)
// ── 7. Verificar tamaño ─────────────────────────────────────────────────────
const pdfBuf = readFileSync(pdfPath)
const pdfKB = (pdfBuf.length / 1024).toFixed(1)
console.log(`\n📄 medium-with-gradient.pdf: ${pdfBuf.length} bytes (${pdfKB} KB)`)
// JPEG de colores sólidos comprime casi igual que el gris neutro.
// El umbral mínimo es > 50 KB (PDF sin JPEG pesa ~28 KB; con JPEG ~60-80 KB).
expect(pdfBuf.length, 'PDF con gradiente real debe ser > 50 KB').toBeGreaterThan(50_000)
// ── 8. Extraer JPEG y analizar píxeles ──────────────────────────────────────
const jpegBuf = await extractJpegFromPdf(pdfBuf)
console.log(` JPEG extraído: ${jpegBuf.length} bytes`)
const { greenPx, amberPx, redPx, total } = await analyzeJpegColors(jpegBuf)
console.log(` Total píxeles: ${total.toLocaleString()}`)
console.log(` 🟢 Verde: ${greenPx.toLocaleString()} px`)
console.log(` 🟡 Ámbar: ${amberPx.toLocaleString()} px`)
console.log(` 🔴 Rojo: ${redPx.toLocaleString()} px`)
const MIN_PX = 100
expect(greenPx, `Píxeles verdes en heatmap (≥ ${MIN_PX}): ${greenPx}`).toBeGreaterThanOrEqual(MIN_PX)
expect(amberPx, `Píxeles ámbar en heatmap (≥ ${MIN_PX}): ${amberPx}`).toBeGreaterThanOrEqual(MIN_PX)
expect(redPx, `Píxeles rojos en heatmap (≥ ${MIN_PX}): ${redPx}`).toBeGreaterThanOrEqual(MIN_PX)
})

View File

@@ -0,0 +1,91 @@
/**
* Smoke test de producción — verifica el flujo completo en la URL live.
* Usa el proceso de ejemplo "Aprobación de crédito" (carga via fetch desde el CDN).
*
* Ejecutar: npx playwright test --config=playwright.prod.config.ts
*/
import { test, expect } from '@playwright/test'
import { readFileSync, mkdirSync } from 'fs'
import { resolve } from 'path'
import { fileURLToPath } from 'url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const OUTPUT_DIR = resolve(__dirname, '__output__')
test.beforeAll(() => {
mkdirSync(OUTPUT_DIR, { recursive: true })
})
test('prod-smoke — import → simulate → export PDF', async ({ page }) => {
// ── 1. Landing page carga correctamente ──────────────────────────────────
await page.goto('/')
await expect(page).toHaveTitle(/Process Cost Platform/)
// Verificar heading principal
await expect(page.getByText('Cuantificá el costo operativo')).toBeVisible({ timeout: 10_000 })
// Verificar que los 3 procesos de ejemplo están presentes
await expect(page.getByText('Aprobación de crédito')).toBeVisible()
await expect(page.getByText('Proceso lineal simple')).toBeVisible()
await expect(page.getByText('Control de calidad con revisión')).toBeVisible()
// ── 2. Cargar proceso de ejemplo "Proceso lineal simple" ──────────────────
await page.getByText('Proceso lineal simple').click()
// Esperar navegación al workspace
await page.waitForURL(/\/workspace\//, { timeout: 30_000 })
// ── 3. Workspace: diagrama BPMN + botón Simular ───────────────────────────
await page.waitForSelector('button:has-text("Simular")', { timeout: 20_000 })
await page.waitForTimeout(1_500)
// Nombre del proceso visible en la topbar
await expect(page.getByText('simple-linear')).toBeVisible()
const simularBtn = page.getByRole('button', { name: 'Simular' })
await expect(simularBtn).not.toBeDisabled({ timeout: 5_000 })
await simularBtn.click()
// ── 4. Reporte carga ──────────────────────────────────────────────────────
await page.waitForURL(/\/report\//, { timeout: 30_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
// KPIs visibles
await expect(page.getByText('COSTO TOTAL ESPERADO')).toBeVisible()
// ── 5. Export PDF ─────────────────────────────────────────────────────────
const pdfPath = resolve(OUTPUT_DIR, 'prod-smoke.pdf')
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dl = await pdfDownload
await dl.saveAs(pdfPath)
const pdfBuffer = readFileSync(pdfPath)
const pdfKB = (pdfBuffer.length / 1024).toFixed(1)
console.log(`\n📄 prod-smoke.pdf: ${pdfBuffer.length} bytes (${pdfKB} KB)`)
expect(pdfBuffer.length, 'PDF debe ser > 40 KB').toBeGreaterThan(40_000)
expect(pdfBuffer.length, 'PDF debe ser < 2 MB').toBeLessThan(2_000_000)
// JPEG heatmap embebido
const jpegMarker = Buffer.from([0xff, 0xd8, 0xff])
const hasJpeg = pdfBuffer.includes(jpegMarker)
console.log(` Heatmap JPEG embebido: ${hasJpeg}`)
expect(hasJpeg, 'PDF de prod debe tener heatmap embebido').toBe(true)
// ── 6. Export CSV ─────────────────────────────────────────────────────────
const csvPath = resolve(OUTPUT_DIR, 'prod-smoke.csv')
const csvDownload = page.waitForEvent('download')
await page.getByRole('button', { name: 'Exportar CSV' }).click()
const dlCsv = await csvDownload
await dlCsv.saveAs(csvPath)
const csvText = readFileSync(csvPath, 'utf-8')
expect(csvText, 'CSV debe tener BOM UTF-8').toMatch(/^/)
expect(csvText, 'CSV debe contener encabezado').toContain('Costo total')
console.log(`\n✅ Prod smoke PASSED — https://process-cost-platform.pages.dev`)
console.log(` PDF: ${pdfKB} KB | CSV: ${csvText.length} chars`)
})

View File

@@ -0,0 +1,85 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`buildCostCompositionOption — estructura del objeto ECharts > snapshot: estructura completa del option del donut 1`] = `
{
"graphic": [
{
"left": "center",
"style": {
"fill": "#1e293b",
"fontFamily": "JetBrains Mono, monospace",
"fontSize": 13,
"fontWeight": "bold",
"text": "USD 1.200,00",
"textAlign": "center",
},
"top": "38%",
"type": "text",
},
],
"legend": {
"bottom": "2%",
"itemHeight": 12,
"itemWidth": 12,
"left": "center",
"textStyle": {
"color": "#475569",
"fontSize": 11,
},
},
"series": [
{
"avoidLabelOverlap": true,
"center": [
"50%",
"44%",
],
"data": [
{
"itemStyle": {
"color": "#3b82f6",
},
"name": "Costo directo",
"value": 1000,
},
{
"itemStyle": {
"color": "#94a3b8",
},
"name": "Costo indirecto (overhead)",
"value": 200,
},
],
"emphasis": {
"label": {
"fontSize": 13,
"fontWeight": "bold",
},
},
"itemStyle": {
"borderColor": "#fff",
"borderRadius": 4,
"borderWidth": 2,
},
"label": {
"fontSize": 11,
"formatter": [Function],
"show": true,
},
"labelLine": {
"length": 10,
"length2": 8,
},
"radius": [
"48%",
"68%",
],
"type": "pie",
},
],
"tooltip": {
"formatter": [Function],
"trigger": "item",
},
}
`;

View File

@@ -0,0 +1,409 @@
/**
* Tests de renderizado DOM de los componentes del reporte.
* bpmn-js y echarts-for-react se mockean (requieren browser/canvas reales).
* useReportData se mockea para inyectar datos controlados.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import React from 'react'
// ─── Mocks de dependencias del browser ───────────────────────────────────────
vi.mock('bpmn-js/lib/Viewer', () => ({
default: class MockViewer {
importXML = vi.fn().mockResolvedValue({ warnings: [] })
get = vi.fn().mockImplementation((svc: string) => {
if (svc === 'canvas') return { zoom: vi.fn(), addMarker: vi.fn(), removeMarker: vi.fn() }
if (svc === 'elementRegistry') return { forEach: vi.fn(), getGraphics: vi.fn().mockReturnValue(null) }
if (svc === 'eventBus') return { on: vi.fn(), off: vi.fn() }
return {}
})
destroy = vi.fn()
},
}))
vi.mock('echarts-for-react', () => ({
default: ({ style }: { option: unknown; style?: React.CSSProperties }) => (
<div data-testid="echarts-chart" style={style} />
),
}))
// ─── Fixtures ─────────────────────────────────────────────────────────────────
import type { ActivitySimResult, SimulationResult } from '@/domain/types'
const mockActivities: ActivitySimResult[] = [
{
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Recibir solicitud',
expectedDirectCost: 500, expectedIndirectCost: 100, expectedTotalCost: 600,
percentOfTotal: 60, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 30, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task2', activityName: 'Procesar pedido',
expectedDirectCost: 250, expectedIndirectCost: 50, expectedTotalCost: 300,
percentOfTotal: 30, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 60, resourceCostBreakdown: [],
},
{
activityId: 'a3', bpmnElementId: 'task3', activityName: 'Notificar cliente',
expectedDirectCost: 83, expectedIndirectCost: 17, expectedTotalCost: 100,
percentOfTotal: 10, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 15, resourceCostBreakdown: [],
},
]
const mockResult: SimulationResult = {
totalCost: 1000,
totalDirectCost: 833,
totalIndirectCost: 167,
totalTimeMinutes: 105,
perActivity: mockActivities,
perResource: [],
warnings: [],
}
// ─── KpiBar ───────────────────────────────────────────────────────────────────
import { KpiBar } from '@/features/report/KpiBar'
describe('KpiBar', () => {
it('renderiza exactamente 4 KPI cards', () => {
render(<KpiBar result={mockResult} currency="USD" />)
const cards = screen.getAllByTestId('kpi-card')
expect(cards).toHaveLength(4)
})
it('la primera card muestra el costo total formateado', () => {
render(<KpiBar result={mockResult} currency="USD" />)
// El valor $1,000.00 debe aparecer en algún lugar del DOM
const cards = screen.getAllByTestId('kpi-card')
const allText = cards.map((c) => c.textContent).join(' ')
expect(allText).toMatch(/1[.,]000/)
})
it('con currency PYG: muestra el valor sin crash', () => {
expect(() => render(<KpiBar result={mockResult} currency="PYG" />)).not.toThrow()
const cards = screen.getAllByTestId('kpi-card')
expect(cards).toHaveLength(4)
})
it('tiempo total aparece formateado en minutos/horas', () => {
render(<KpiBar result={mockResult} currency="USD" />)
// 105 min = 1h 45min
const text = document.body.textContent ?? ''
expect(text).toMatch(/1h|105/)
})
it('cantidad de actividades es visible', () => {
render(<KpiBar result={mockResult} currency="USD" />)
const text = document.body.textContent ?? ''
expect(text).toMatch(/3/)
})
})
// ─── WarningsBanner ───────────────────────────────────────────────────────────
import { WarningsBanner } from '@/features/report/WarningsBanner'
describe('WarningsBanner', () => {
it('no renderiza nada cuando no hay warnings', () => {
const { container } = render(<WarningsBanner warnings={[]} />)
expect(container.firstChild).toBeNull()
})
it('renderiza el banner cuando hay warnings', () => {
render(<WarningsBanner warnings={['Loop detectado en task1']} />)
expect(screen.getByText(/Loop detectado/)).toBeInTheDocument()
})
it('con múltiples warnings: muestra todos', () => {
render(<WarningsBanner warnings={['Warning 1', 'Warning 2', 'Warning 3']} />)
expect(screen.getByText(/Warning 1/)).toBeInTheDocument()
expect(screen.getByText(/Warning 2/)).toBeInTheDocument()
expect(screen.getByText(/Warning 3/)).toBeInTheDocument()
})
it('warning de loop: muestra nota de impacto en resultados', () => {
render(<WarningsBanner warnings={['Loop detectado en "Inspección" forma un ciclo']} />)
expect(screen.getByText(/Impacto en los resultados/i)).toBeInTheDocument()
})
it('warning sin loop: NO muestra nota de impacto', () => {
render(<WarningsBanner warnings={['Actividad "X" no encontrada en diagrama']} />)
expect(screen.queryByText(/Impacto en los resultados/i)).not.toBeInTheDocument()
})
})
// ─── ActivitiesTable ─────────────────────────────────────────────────────────
import { ActivitiesTable } from '@/features/report/ActivitiesTable'
describe('ActivitiesTable', () => {
it('renderiza exactamente N filas donde N = perActivity.length', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
currency="USD"
highlightedId={null}
onRowClick={vi.fn()}
/>
)
// getAllByRole('row') incluye el header row, entonces total = N + 1
const rows = screen.getAllByRole('row')
expect(rows).toHaveLength(mockActivities.length + 1)
})
it('los nombres de las actividades aparecen en la tabla', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
currency="USD"
highlightedId={null}
onRowClick={vi.fn()}
/>
)
expect(screen.getByText('Recibir solicitud')).toBeInTheDocument()
expect(screen.getByText('Procesar pedido')).toBeInTheDocument()
expect(screen.getByText('Notificar cliente')).toBeInTheDocument()
})
it('click en fila dispara onRowClick con el bpmnElementId correcto', () => {
const onRowClick = vi.fn()
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
currency="USD"
highlightedId={null}
onRowClick={onRowClick}
/>
)
const row = screen.getByText('Procesar pedido').closest('tr')!
fireEvent.click(row)
expect(onRowClick).toHaveBeenCalledWith('task2')
expect(onRowClick).toHaveBeenCalledTimes(1)
})
it('fila resaltada tiene clase/estado visual distinto', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
currency="USD"
highlightedId="task1"
onRowClick={vi.fn()}
/>
)
const highlightedRow = screen.getByText('Recibir solicitud').closest('tr')!
// Verificamos que el atributo data-state='selected' está presente en la fila resaltada
expect(highlightedRow).toHaveAttribute('data-state', 'selected')
})
it('tabla sin actividades: renderiza solo el header (0 data rows)', () => {
render(
<ActivitiesTable
perActivity={[]}
mode="relative"
currency="USD"
highlightedId={null}
onRowClick={vi.fn()}
/>
)
const rows = screen.getAllByRole('row')
expect(rows).toHaveLength(1) // solo el header
})
})
// ─── MethodologyFooter ────────────────────────────────────────────────────────
import { MethodologyFooter } from '@/features/report/MethodologyFooter'
describe('MethodologyFooter', () => {
it('renderiza sin crash con un proceso válido', () => {
const proc = {
id: 'p1', name: 'Proceso Test', clientName: '',
bpmnXml: '<def/>', currency: 'USD',
overheadPercentage: 0.2, createdAt: 0, updatedAt: 0,
}
expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow()
})
it('muestra el overhead configurado', () => {
const proc = {
id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD',
overheadPercentage: 0.25, createdAt: 0, updatedAt: 0,
}
render(<MethodologyFooter process={proc} />)
const text = document.body.textContent ?? ''
expect(text).toMatch(/25%/)
})
it('contiene las palabras clave de la metodología', () => {
const proc = {
id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD',
overheadPercentage: 0.2, createdAt: 0, updatedAt: 0,
}
render(<MethodologyFooter process={proc} />)
const text = document.body.textContent ?? ''
expect(text).toMatch(/Nota metodológica|metodol/i)
expect(text).toMatch(/probabilidad/i)
})
})
// ─── ReportPage — empty state (sin simulación) ─────────────────────────────────
vi.mock('@/features/report/useReportData', () => ({
useReportData: vi.fn(),
}))
import { useReportData } from '@/features/report/useReportData'
import { ReportPage } from '@/features/report/ReportPage'
// Mock de TanStack Router para tests de ReportPage
vi.mock('@tanstack/react-router', async (importActual) => {
const actual = await importActual<typeof import('@tanstack/react-router')>()
return {
...actual,
useParams: vi.fn().mockReturnValue({ processId: 'test-proc-id' }),
useNavigate: vi.fn().mockReturnValue(vi.fn()),
}
})
describe('ReportPage — empty state (sin simulación)', () => {
beforeEach(() => {
vi.mocked(useReportData).mockReturnValue({
process: null,
simulation: null,
activities: [],
resources: [],
isLoading: false,
error: 'Sin simulación disponible. Volvé al workspace y ejecutá "Simular" primero.',
})
})
it('muestra mensaje de error cuando no hay simulación', () => {
render(<ReportPage />)
expect(screen.getByText(/Sin simulación disponible/i)).toBeInTheDocument()
})
it('muestra botón para ir al workspace', () => {
render(<ReportPage />)
const btn = screen.getByText(/Volver a configurar y simular/i)
expect(btn).toBeInTheDocument()
})
})
describe('ReportPage — loading state', () => {
beforeEach(() => {
vi.mocked(useReportData).mockReturnValue({
process: null,
simulation: null,
activities: [],
resources: [],
isLoading: true,
error: null,
})
})
it('muestra skeleton de carga (no pantalla en blanco)', () => {
render(<ReportPage />)
// El skeleton usa animate-pulse en el contenedor
const skeleton = document.querySelector('.animate-pulse')
expect(skeleton).not.toBeNull()
})
})
describe('ReportPage — con datos completos', () => {
const mockProcess = {
id: 'test-proc-id',
name: 'Proceso de Aprobación de Crédito',
clientName: 'Banco ABC',
bpmnXml: '<?xml version="1.0"?><definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"><process id="p"><startEvent id="s"><outgoing>f1</outgoing></startEvent><task id="t1" name="Task 1"><incoming>f1</incoming><outgoing>f2</outgoing></task><endEvent id="e"><incoming>f2</incoming></endEvent><sequenceFlow id="f1" sourceRef="s" targetRef="t1"/><sequenceFlow id="f2" sourceRef="t1" targetRef="e"/></process></definitions>',
currency: 'USD',
overheadPercentage: 0.2,
createdAt: Date.now() - 1000 * 60 * 30,
updatedAt: Date.now() - 1000 * 60 * 30,
}
const mockSimulation = {
id: 'sim-1',
processId: 'test-proc-id',
executedAt: Date.now() - 1000 * 60 * 10,
result: mockResult,
}
beforeEach(() => {
vi.mocked(useReportData).mockReturnValue({
process: mockProcess,
simulation: mockSimulation,
activities: [],
resources: [],
isLoading: false,
error: null,
})
})
it('renderiza el h1 con el nombre del proceso', () => {
render(<ReportPage />)
const h1 = screen.getByRole('heading', { level: 1 })
expect(h1).toHaveTextContent('Proceso de Aprobación de Crédito')
})
it('hay exactamente 4 KPI cards', () => {
render(<ReportPage />)
const cards = screen.getAllByTestId('kpi-card')
expect(cards).toHaveLength(4)
})
it('hay al menos 2 gráficos ECharts (TopActivities + Donut siempre; CostByResource solo con recursos)', () => {
render(<ReportPage />)
const charts = screen.getAllByTestId('echarts-chart')
// TopActivities y CostComposition siempre renderizan.
// CostByResource muestra empty-state cuando resources=[] y sin breakdown.
expect(charts.length).toBeGreaterThanOrEqual(2)
})
it('la tabla tiene N+1 rows (N actividades + 1 header)', () => {
render(<ReportPage />)
const rows = screen.getAllByRole('row')
expect(rows).toHaveLength(mockActivities.length + 1)
})
it('el nombre del cliente aparece en el reporte', () => {
render(<ReportPage />)
expect(screen.getByText(/Banco ABC/)).toBeInTheDocument()
})
it('el timestamp de simulación es visible', () => {
render(<ReportPage />)
const text = document.body.textContent ?? ''
// "Simulado el" debe aparecer en el header
expect(text).toMatch(/Simulado el/i)
})
it('no hay banner de warnings cuando no hay warnings', () => {
render(<ReportPage />)
expect(screen.queryByText(/Advertencia/i)).not.toBeInTheDocument()
})
it('con warnings: el banner aparece', () => {
vi.mocked(useReportData).mockReturnValue({
process: mockProcess,
simulation: {
...mockSimulation,
result: { ...mockResult, warnings: ['Loop detectado en "Inspección"'] },
},
activities: [],
resources: [],
isLoading: false,
error: null,
})
render(<ReportPage />)
expect(screen.getByText(/Loop detectado/)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,293 @@
/**
* Tests de derivación de datos del reporte.
* Para cada uno de los 3 sample BPMNs, simula con costos predefinidos
* y verifica invariantes matemáticos y de presentación.
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { v4 as uuidv4 } from 'uuid'
import { parseBpmnXml, extractActivityElements, extractGatewayElements } from '@/domain/bpmn-parser'
import { runSimulation } from '@/domain/simulation'
import { buildCostCompositionOption, buildTopActivitiesOption, aggregateResourceCosts } from '@/lib/chart-options'
import { formatCurrency } from '@/lib/format'
import { bpmnTypeToGatewayType } from '@/domain/types'
import type { Activity, GatewayConfig, Resource, SimulationInput } from '@/domain/types'
function loadBpmn(name: string): string {
return readFileSync(resolve(__dirname, '../../../public/sample-processes', name), 'utf-8')
}
function buildSimInput(xml: string, directCost: number, overhead: number, currency = 'USD'): SimulationInput {
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
const gwEls = extractGatewayElements(graph)
const processId = 'report-test'
const activities = new Map<string, Activity>(
actEls.map((el) => [el.bpmnElementId, {
id: uuidv4(), processId, bpmnElementId: el.bpmnElementId,
name: el.name, type: el.type,
directCostFixed: directCost,
executionTimeMinutes: 30,
assignedResources: [],
}])
)
const gateways = new Map<string, GatewayConfig>(
gwEls.map((el) => {
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
const n = el.outgoing.length
return [el.bpmnElementId, {
id: uuidv4(), processId, bpmnElementId: el.bpmnElementId,
gatewayType: gwType,
branches: el.outgoing.map((flowId) => ({
flowId, targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
probability: gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(4)),
})),
}]
})
)
return {
processXml: xml,
activities,
gateways,
resources: new Map<string, Resource>(),
globalSettings: { currency, overheadPercentage: overhead },
}
}
// ─── INVARIANTES MATEMÁTICOS ─────────────────────────────────────────────────
describe('invariantes matemáticos — los 3 BPMNs', () => {
const BPMNS = [
{ name: 'simple-linear.bpmn', overhead: 0.2, label: 'simple-linear' },
{ name: 'medium-with-gateways.bpmn', overhead: 0.15, label: 'medium-gateways' },
{ name: 'complex-with-loop.bpmn', overhead: 0.25, label: 'complex-loop' },
]
for (const { name, overhead, label } of BPMNS) {
describe(label, () => {
const xml = loadBpmn(name)
const result = runSimulation(buildSimInput(xml, 100, overhead))
it('directo + indirecto = total (dentro de tolerancia float)', () => {
expect(result.totalDirectCost + result.totalIndirectCost).toBeCloseTo(result.totalCost, 6)
})
it('overhead indirecto = directCost × overheadPct', () => {
expect(result.totalIndirectCost).toBeCloseTo(result.totalDirectCost * overhead, 6)
})
it('suma de percentOfTotal de todas las actividades ≈ 100%', () => {
const sum = result.perActivity.reduce((s, a) => s + a.percentOfTotal, 0)
expect(sum).toBeCloseTo(100, 1)
})
it('suma de expectedTotalCost de actividades ≈ totalCost', () => {
const sum = result.perActivity.reduce((s, a) => s + a.expectedTotalCost, 0)
expect(sum).toBeCloseTo(result.totalCost, 4)
})
it('perActivity ordenado desc por expectedTotalCost', () => {
for (let i = 0; i < result.perActivity.length - 1; i++) {
expect(result.perActivity[i].expectedTotalCost).toBeGreaterThanOrEqual(
result.perActivity[i + 1].expectedTotalCost
)
}
})
it('cantidad de filas de tabla = cantidad de Activity parseadas', () => {
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
expect(result.perActivity).toHaveLength(actEls.length)
})
it('cada actividad: directCost + indirectCost = totalCost (dentro de tolerancia)', () => {
for (const act of result.perActivity) {
expect(act.expectedDirectCost + act.expectedIndirectCost).toBeCloseTo(act.expectedTotalCost, 4)
}
})
it('sin recursos: suma de resourceCostBreakdown = 0 para cada actividad', () => {
for (const act of result.perActivity) {
const resourceSum = act.resourceCostBreakdown.reduce((s, r) => s + r.cost, 0)
expect(resourceSum).toBeCloseTo(0, 6)
}
})
it('totalTimeMinutes > 0 cuando hay actividades con tiempo > 0', () => {
expect(result.totalTimeMinutes).toBeGreaterThan(0)
})
})
}
})
// ─── FORMATO DE MONEDA EN EL REPORTE ─────────────────────────────────────────
describe('formato de moneda en datos del reporte', () => {
it('USD: KPI costo total formateado correctamente ($1,200.00)', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0.2, 'USD'))
const formatted = formatCurrency(result.totalCost, 'USD')
// Simple-linear: 5 actividades × $100 × 1.2 = $600
expect(formatted).toMatch(/600/)
expect(formatted).toMatch(/\$|USD/)
})
it('PYG: KPI costo total formateado correctamente (sin decimales en PYG)', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100000, 0.2, 'PYG'))
const formatted = formatCurrency(result.totalCost, 'PYG')
expect(formatted).toBeTruthy()
expect(formatted).toMatch(/600/)
})
it('USD vs PYG: misma cantidad produce representaciones distintas', () => {
const amountUsd = formatCurrency(1000, 'USD')
const amountPyg = formatCurrency(1000, 'PYG')
expect(amountUsd).not.toBe(amountPyg)
})
})
// ─── DONUT CHART OPTION — snapshot del objeto ECharts ────────────────────────
describe('buildCostCompositionOption — estructura del objeto ECharts', () => {
it('tiene exactamente 1 serie de tipo pie', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0.2))
const option = buildCostCompositionOption(result, 'USD')
const series = option.series as any[]
expect(series).toHaveLength(1)
expect(series[0].type).toBe('pie')
})
it('con overhead > 0: data tiene 2 items (directo + indirecto)', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0.2))
const option = buildCostCompositionOption(result, 'USD')
const series = option.series as any[]
expect(series[0].data).toHaveLength(2)
expect(series[0].data[0].name).toBe('Costo directo')
expect(series[0].data[1].name).toBe('Costo indirecto (overhead)')
})
it('con overhead = 0: data tiene solo 1 item (solo directo)', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0))
const option = buildCostCompositionOption(result, 'USD')
const series = option.series as any[]
expect(series[0].data).toHaveLength(1)
expect(series[0].data[0].name).toBe('Costo directo')
})
it('los valores de data suman al totalCost', () => {
const xml = loadBpmn('medium-with-gateways.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0.2))
const option = buildCostCompositionOption(result, 'USD')
const series = option.series as any[]
const dataSum = (series[0].data as { value: number }[]).reduce((s, d) => s + d.value, 0)
expect(dataSum).toBeCloseTo(result.totalCost, 4)
})
it('el graphic label contiene el totalCost formateado', () => {
const result = { totalDirectCost: 1000, totalIndirectCost: 200, totalCost: 1200 }
const option = buildCostCompositionOption(result, 'USD')
const graphic = option.graphic as any[]
expect(graphic[0].style.text).toContain('1')
expect(graphic[0].style.text).toContain('200')
})
it('snapshot: estructura completa del option del donut', () => {
const result = { totalDirectCost: 1000, totalIndirectCost: 200, totalCost: 1200 }
const option = buildCostCompositionOption(result, 'USD')
expect(option).toMatchSnapshot()
})
})
// ─── TOP ACTIVITIES CHART — estructura ───────────────────────────────────────
describe('buildTopActivitiesOption — estructura', () => {
it('serie de tipo bar con hasta 10 items', () => {
const xml = loadBpmn('medium-with-gateways.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0.2))
const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD')
const series = option.series as any[]
expect(series[0].type).toBe('bar')
expect(series[0].data.length).toBeLessThanOrEqual(10)
expect(series[0].data.length).toBeGreaterThan(0)
})
it('la primera barra (actividad más costosa) tiene color del extremo cálido', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 200, 0))
// En modo relativo con varianza real, la 1ra actividad → t=1 → rojo
// En simple-linear todos cuestan igual → NEUTRAL_HEATMAP_COLOR
const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD')
const series = option.series as any[]
// Verifica que itemStyle.color existe en cada barra
series[0].data.forEach((item: any) => {
expect(item.itemStyle).toBeDefined()
expect(item.itemStyle.color).toMatch(/^#[0-9a-f]{6}$/)
})
})
it('yAxis data contiene los nombres de actividades (truncados)', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0))
const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD')
const yAxis = option.yAxis as any
expect(yAxis.data).toHaveLength(result.perActivity.length)
// Los nombres deben existir (no vacíos)
yAxis.data.forEach((name: string) => expect(name.length).toBeGreaterThan(0))
})
})
// ─── AGGREGATE RESOURCE COSTS ─────────────────────────────────────────────────
describe('aggregateResourceCosts', () => {
it('sin recursos: activeTypes vacío, totales en cero', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0))
const { byType, activeTypes } = aggregateResourceCosts(result.perActivity, [])
expect(activeTypes).toHaveLength(0)
expect(Object.keys(byType)).toHaveLength(0)
})
it('con recursos de tipo role: suma correctamente por tipo', () => {
const resource: Resource = {
id: 'r1', processId: 'p1', name: 'Analista', type: 'role', costPerHour: 60,
}
const perActivity = [{
activityId: 'a1', bpmnElementId: 't1', activityName: 'Task', type: 'task' as const,
expectedDirectCost: 60, expectedIndirectCost: 12, expectedTotalCost: 72,
percentOfTotal: 100, executionProbability: 1, expectedExecutions: 1,
executionTimeMinutes: 60,
resourceCostBreakdown: [{ resourceId: 'r1', resourceName: 'Analista', cost: 60 }],
}]
const { byType, activeTypes } = aggregateResourceCosts(perActivity, [resource])
expect(activeTypes).toContain('role')
expect(byType.role).toBeCloseTo(60)
})
})
// ─── EMPTY STATE / ERROR STATE ───────────────────────────────────────────────
describe('estados edge del reporte', () => {
it('resultado con 0 actividades: perActivity vacío → totalCost = 0', () => {
const result = runSimulation({
processXml: `<?xml version="1.0"?><definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL">
<process id="p"><startEvent id="s"><outgoing>f1</outgoing></startEvent>
<endEvent id="e"><incoming>f1</incoming></endEvent>
<sequenceFlow id="f1" sourceRef="s" targetRef="e"/></process></definitions>`,
activities: new Map(),
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
})
expect(result.perActivity).toHaveLength(0)
expect(result.totalCost).toBe(0)
expect(result.warnings).toHaveLength(0)
})
})

View File

@@ -0,0 +1,251 @@
/**
* Tests permanentes del parser BPMN.
* Protegen contra regresiones cuando se modifique parseBpmnXml,
* extractActivityElements o extractGatewayElements.
*
* Conteos esperados (fijos, derivados del XML de los sample BPMNs):
*
* simple-linear.bpmn
* nodes total : 7 (1 startEvent + 5 tasks + 1 endEvent)
* flows total : 6
* tasks : 5
* gateways : 0
* events : 2
* divergentes : 0
*
* medium-with-gateways.bpmn
* nodes total : 17 (1 start + 11 tasks + 4 gateways + 1 end)
* flows total : 19
* tasks : 11
* gateways : 4 (gw_datos XOR, gw_parallel_split AND, gw_parallel_join AND, gw_decision XOR)
* events : 2
* divergentes : 3 (gw_datos:2, gw_parallel_split:2, gw_decision:2)
*
* complex-with-loop.bpmn
* nodes total : 13 (1 start + 9 tasks + 2 gateways + 1 end)
* flows total : 14
* tasks : 9
* gateways : 2 (gw_resultado XOR 3-salidas, gw_merge XOR convergente)
* events : 2
* divergentes : 1 (gw_resultado:3)
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import {
parseBpmnXml,
extractActivityElements,
extractGatewayElements,
BpmnParseError,
} from '@/domain/bpmn-parser'
import { isTaskNode, isGatewayNode } from '@/domain/types'
function loadSample(name: string): string {
return readFileSync(resolve(__dirname, '../../public/sample-processes', name), 'utf-8')
}
function countByCategory(graph: ReturnType<typeof parseBpmnXml>) {
let tasks = 0, gateways = 0, events = 0, unknown = 0
for (const node of graph.nodes.values()) {
if (isTaskNode(node.type)) tasks++
else if (isGatewayNode(node.type)) gateways++
else if (['startEvent', 'endEvent', 'intermediateThrowEvent', 'intermediateCatchEvent'].includes(node.type)) events++
else unknown++
}
return { tasks, gateways, events, unknown }
}
// ─── simple-linear.bpmn ──────────────────────────────────────────────────────
describe('parser / simple-linear.bpmn', () => {
const xml = loadSample('simple-linear.bpmn')
it('parsea sin lanzar error', () => {
expect(() => parseBpmnXml(xml)).not.toThrow()
})
it('grafo: 7 nodos y 6 flujos de secuencia', () => {
const g = parseBpmnXml(xml)
expect(g.nodes.size).toBe(7)
expect(g.flows.size).toBe(6)
})
it('grafo: 5 tasks | 0 gateways | 2 eventos', () => {
const g = parseBpmnXml(xml)
const { tasks, gateways, events, unknown } = countByCategory(g)
expect(tasks).toBe(5)
expect(gateways).toBe(0)
expect(events).toBe(2)
expect(unknown).toBe(0)
})
it('1 startEvent detectado como punto de entrada', () => {
const g = parseBpmnXml(xml)
expect(g.startEventIds).toHaveLength(1)
expect(g.startEventIds[0]).toBe('start')
})
it('extractActivityElements: 5 actividades con nombres correctos', () => {
const g = parseBpmnXml(xml)
const acts = extractActivityElements(g)
expect(acts).toHaveLength(5)
const names = acts.map((a) => a.name)
expect(names).toContain('Recibir solicitud')
expect(names).toContain('Verificar documentación')
expect(names).toContain('Procesar pedido')
expect(names).toContain('Notificar al cliente')
expect(names).toContain('Archivar expediente')
acts.forEach((a) => expect(a.type).toBe('task'))
})
it('extractGatewayElements: 0 gateways divergentes (proceso lineal)', () => {
const g = parseBpmnXml(xml)
const gws = extractGatewayElements(g)
expect(gws).toHaveLength(0)
})
})
// ─── medium-with-gateways.bpmn ───────────────────────────────────────────────
describe('parser / medium-with-gateways.bpmn', () => {
const xml = loadSample('medium-with-gateways.bpmn')
it('parsea sin lanzar error', () => {
expect(() => parseBpmnXml(xml)).not.toThrow()
})
it('grafo: 17 nodos y 19 flujos de secuencia', () => {
const g = parseBpmnXml(xml)
expect(g.nodes.size).toBe(17)
expect(g.flows.size).toBe(19)
})
it('grafo: 11 tasks | 4 gateways | 2 eventos', () => {
const g = parseBpmnXml(xml)
const { tasks, gateways, events, unknown } = countByCategory(g)
expect(tasks).toBe(11)
expect(gateways).toBe(4)
expect(events).toBe(2)
expect(unknown).toBe(0)
})
it('extractActivityElements: exactamente 11 actividades', () => {
const g = parseBpmnXml(xml)
const acts = extractActivityElements(g)
expect(acts).toHaveLength(11)
const ids = acts.map((a) => a.bpmnElementId)
expect(ids).toContain('task_recv')
expect(ids).toContain('task_valid')
expect(ids).toContain('task_pedir_docs')
expect(ids).toContain('task_score')
expect(ids).toContain('task_bureau')
expect(ids).toContain('task_empleador')
expect(ids).toContain('task_analisis')
expect(ids).toContain('task_aprobar')
expect(ids).toContain('task_rechazar')
expect(ids).toContain('task_desembolso')
expect(ids).toContain('task_archivo')
})
it('extractGatewayElements: 3 gateways divergentes con salidas correctas', () => {
const g = parseBpmnXml(xml)
const gws = extractGatewayElements(g)
expect(gws).toHaveLength(3)
const byId = Object.fromEntries(gws.map((gw) => [gw.bpmnElementId, gw]))
// gw_datos: XOR con 2 salidas
expect(byId['gw_datos']).toBeDefined()
expect(byId['gw_datos'].outgoing).toHaveLength(2)
expect(byId['gw_datos'].gatewayType).toBe('exclusiveGateway')
// gw_parallel_split: AND con 2 salidas
expect(byId['gw_parallel_split']).toBeDefined()
expect(byId['gw_parallel_split'].outgoing).toHaveLength(2)
expect(byId['gw_parallel_split'].gatewayType).toBe('parallelGateway')
// gw_decision: XOR con 2 salidas
expect(byId['gw_decision']).toBeDefined()
expect(byId['gw_decision'].outgoing).toHaveLength(2)
expect(byId['gw_decision'].gatewayType).toBe('exclusiveGateway')
// gw_parallel_join NO debe estar (convergente: 1 salida)
expect(byId['gw_parallel_join']).toBeUndefined()
})
})
// ─── complex-with-loop.bpmn ───────────────────────────────────────────────────
describe('parser / complex-with-loop.bpmn', () => {
const xml = loadSample('complex-with-loop.bpmn')
it('parsea sin lanzar error', () => {
expect(() => parseBpmnXml(xml)).not.toThrow()
})
it('grafo: 13 nodos y 14 flujos de secuencia', () => {
const g = parseBpmnXml(xml)
expect(g.nodes.size).toBe(13)
expect(g.flows.size).toBe(14)
})
it('grafo: 9 tasks | 2 gateways | 2 eventos', () => {
const g = parseBpmnXml(xml)
const { tasks, gateways, events, unknown } = countByCategory(g)
expect(tasks).toBe(9)
expect(gateways).toBe(2)
expect(events).toBe(2)
expect(unknown).toBe(0)
})
it('extractActivityElements: exactamente 9 actividades', () => {
const g = parseBpmnXml(xml)
const acts = extractActivityElements(g)
expect(acts).toHaveLength(9)
const ids = acts.map((a) => a.bpmnElementId)
expect(ids).toContain('task_preparar')
expect(ids).toContain('task_inspeccion')
expect(ids).toContain('task_documentar')
expect(ids).toContain('task_correcciones')
expect(ids).toContain('task_rechazo')
expect(ids).toContain('task_informe_rechazo')
expect(ids).toContain('task_entrega')
expect(ids).toContain('task_certificado')
expect(ids).toContain('task_registro')
})
it('extractGatewayElements: 1 gateway divergente (gw_resultado con 3 salidas)', () => {
const g = parseBpmnXml(xml)
const gws = extractGatewayElements(g)
expect(gws).toHaveLength(1)
expect(gws[0].bpmnElementId).toBe('gw_resultado')
expect(gws[0].outgoing).toHaveLength(3)
expect(gws[0].gatewayType).toBe('exclusiveGateway')
// gw_merge es convergente (1 salida) — no debe aparecer
})
it('el flujo de loop existe en el grafo (correc_inspeccion apunta a task_inspeccion)', () => {
const g = parseBpmnXml(xml)
const loopFlow = g.flows.get('flow_correc_inspeccion')
expect(loopFlow).toBeDefined()
expect(loopFlow!.sourceRef).toBe('task_correcciones')
expect(loopFlow!.targetRef).toBe('task_inspeccion')
})
})
// ─── Casos de error del parser ────────────────────────────────────────────────
describe('parser / manejo de errores', () => {
it('lanza BpmnParseError si el XML no tiene elemento <process>', () => {
expect(() => parseBpmnXml('<definitions><notAProcess/></definitions>')).toThrow(BpmnParseError)
})
it('lanza BpmnParseError si el XML está malformado', () => {
expect(() => parseBpmnXml('<definitions><process id="p"><unclosed')).toThrow(BpmnParseError)
})
it('lanza BpmnParseError si el proceso no tiene startEvent', () => {
const xml = `<?xml version="1.0"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL">
<process id="p"><task id="t1" name="Solo tarea"/></process>
</definitions>`
expect(() => parseBpmnXml(xml)).toThrow(BpmnParseError)
})
})

View File

@@ -0,0 +1,157 @@
/**
* Tests permanentes de validación de gateways.
* Protegen las reglas de negocio: XOR debe sumar 1, AND no tiene probs editables.
*/
import { describe, it, expect } from 'vitest'
import { isXorSumValid } from '@/features/workspace/GatewayPanel'
import { runSimulation } from '@/domain/simulation'
import type { GatewayConfig, SimulationInput, Activity } from '@/domain/types'
import { v4 as uuidv4 } from 'uuid'
import { readFileSync } from 'fs'
import { resolve } from 'path'
function makeBranches(probs: number[]): GatewayConfig['branches'] {
return probs.map((p, i) => ({ flowId: `f${i}`, targetElementId: `t${i}`, probability: p }))
}
function makeXorGateway(probs: number[]): GatewayConfig {
return {
id: uuidv4(), processId: 'p', bpmnElementId: 'gw1',
gatewayType: 'exclusive',
branches: makeBranches(probs),
}
}
function makeParallelGateway(n: number): GatewayConfig {
return {
id: uuidv4(), processId: 'p', bpmnElementId: 'gw1',
gatewayType: 'parallel',
branches: makeBranches(Array(n).fill(1.0)),
}
}
// ─── isXorSumValid ────────────────────────────────────────────────────────────
describe('isXorSumValid', () => {
it('válido: suma exactamente 1.0', () => {
expect(isXorSumValid(makeXorGateway([0.6, 0.4]))).toBe(true)
})
it('válido: suma = 1.0 con 3 ramas (0.5 + 0.3 + 0.2)', () => {
expect(isXorSumValid(makeXorGateway([0.5, 0.3, 0.2]))).toBe(true)
})
it('válido: tolerancia de 0.5% — 0.999 se acepta como 1.0', () => {
expect(isXorSumValid(makeXorGateway([0.499, 0.501]))).toBe(true)
})
it('inválido: suma = 0.5 (faltan 50%)', () => {
expect(isXorSumValid(makeXorGateway([0.3, 0.2]))).toBe(false)
})
it('inválido: suma = 1.5 (excede 100%)', () => {
expect(isXorSumValid(makeXorGateway([0.8, 0.7]))).toBe(false)
})
it('inválido: suma = 0 (gateways sin configurar)', () => {
expect(isXorSumValid(makeXorGateway([0, 0]))).toBe(false)
})
it('gateway paralelo siempre válido (no aplica regla de suma)', () => {
expect(isXorSumValid(makeParallelGateway(2))).toBe(true)
expect(isXorSumValid(makeParallelGateway(3))).toBe(true)
})
it('gateway inclusivo tratado como exclusivo — aplica regla de suma', () => {
const gw: GatewayConfig = { ...makeXorGateway([0.6, 0.4]), gatewayType: 'inclusive' }
// inclusive usa isXorSumValid solo para el display; el motor lo trata igual
// Aquí verificamos que la función no lanza para gateways inclusive
expect(() => isXorSumValid(gw)).not.toThrow()
})
})
// ─── Motor: gateways con distribución 70/30 sobre medium BPMN ────────────────
describe('motor / propagación con GatewayConfig correcta', () => {
const xml = readFileSync(resolve(__dirname, '../../public/sample-processes/medium-with-gateways.bpmn'), 'utf-8')
function buildSimInput(datosProbs: [number, number], decisionProbs: [number, number]): SimulationInput {
const processId = 'gv-test'
const actIds = ['task_recv', 'task_valid', 'task_pedir_docs', 'task_score',
'task_bureau', 'task_empleador', 'task_analisis',
'task_aprobar', 'task_rechazar', 'task_desembolso', 'task_archivo']
const activities = new Map<string, Activity>(
actIds.map((id) => [id, {
id: uuidv4(), processId, bpmnElementId: id, name: id,
type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
}])
)
const gateways = new Map<string, GatewayConfig>([
['gw_datos', {
id: uuidv4(), processId, bpmnElementId: 'gw_datos', gatewayType: 'exclusive',
branches: [
{ flowId: 'flow_gw1_completo', targetElementId: 'task_score', probability: datosProbs[0] },
{ flowId: 'flow_gw1_incompleto', targetElementId: 'task_pedir_docs', probability: datosProbs[1] },
],
}],
['gw_parallel_split', {
id: uuidv4(), processId, bpmnElementId: 'gw_parallel_split', gatewayType: 'parallel',
branches: [
{ flowId: 'flow_para_bureau', targetElementId: 'task_bureau', probability: 1.0 },
{ flowId: 'flow_para_empleador', targetElementId: 'task_empleador', probability: 1.0 },
],
}],
['gw_decision', {
id: uuidv4(), processId, bpmnElementId: 'gw_decision', gatewayType: 'exclusive',
branches: [
{ flowId: 'flow_dec_aprobado', targetElementId: 'task_aprobar', probability: decisionProbs[0] },
{ flowId: 'flow_dec_rechazado', targetElementId: 'task_rechazar', probability: decisionProbs[1] },
],
}],
])
return { processXml: xml, activities, gateways, resources: new Map(), globalSettings: { currency: 'USD', overheadPercentage: 0 } }
}
it('rama "completo" 70% → task_score tiene prob ≈ 0.7', () => {
const result = runSimulation(buildSimInput([0.7, 0.3], [0.8, 0.2]))
const score = result.perActivity.find((a) => a.bpmnElementId === 'task_score')
expect(score?.executionProbability).toBeCloseTo(0.7, 2)
})
it('rama "incompleto" 30% → task_pedir_docs tiene prob ≈ 0.3', () => {
const result = runSimulation(buildSimInput([0.7, 0.3], [0.8, 0.2]))
const pedir = result.perActivity.find((a) => a.bpmnElementId === 'task_pedir_docs')
expect(pedir?.executionProbability).toBeCloseTo(0.3, 2)
})
it('task_aprobar tiene prob = 0.8 (task_analisis converge ambos caminos a 1.0, luego 80% decision)', () => {
// task_analisis recibe:
// - camino completo (70%): AND join correcto → 0.7
// - camino incompleto (30%): task_pedir_docs → 0.3
// - suma XOR: 0.7 + 0.3 = 1.0
// gw_decision → task_aprobar: 1.0 × 0.8 = 0.8
const result = runSimulation(buildSimInput([0.7, 0.3], [0.8, 0.2]))
const analisis = result.perActivity.find((a) => a.bpmnElementId === 'task_analisis')
const aprobar = result.perActivity.find((a) => a.bpmnElementId === 'task_aprobar')
const rechazar = result.perActivity.find((a) => a.bpmnElementId === 'task_rechazar')
expect(analisis?.executionProbability).toBeCloseTo(1.0, 2)
expect(aprobar?.executionProbability).toBeCloseTo(0.8, 2)
expect(rechazar?.executionProbability).toBeCloseTo(0.2, 2)
})
it('gateway AND: bureau y empleador tienen la misma prob que score', () => {
const result = runSimulation(buildSimInput([1.0, 0.0], [0.5, 0.5]))
const bureau = result.perActivity.find((a) => a.bpmnElementId === 'task_bureau')
const empleador = result.perActivity.find((a) => a.bpmnElementId === 'task_empleador')
// Ambos deben tener la misma probabilidad (AND paralelo)
expect(bureau?.executionProbability).toBeCloseTo(empleador?.executionProbability ?? -1, 2)
})
it('con probs que no suman 1 el motor no explota, solo produce resultados parciales', () => {
// Esto valida que el motor es robusto aunque la UI deba prevenirlo
expect(() => runSimulation(buildSimInput([0.3, 0.3], [0.5, 0.5]))).not.toThrow()
})
})

View File

@@ -0,0 +1,331 @@
/**
* Tests permanentes de persistencia en IndexedDB vía Dexie.
* Usan fake-indexeddb (parcheado en tests/setup.ts) para correr en Node.
*
* Cada test limpia las tablas en beforeEach para garantizar aislamiento.
* Verifican que los repositorios escriben y recuperan datos correctamente.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { db } from '@/persistence/db'
import {
processRepo,
activityRepo,
gatewayRepo,
resourceRepo,
simulationRepo,
} from '@/persistence/repositories'
import type { Process, Activity, GatewayConfig, Resource, Simulation, SimulationResult } from '@/domain/types'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeProcess(id = 'proc-1'): Process {
return {
id,
name: 'Proceso de prueba',
clientName: 'Cliente Test',
bpmnXml: '<definitions/>',
currency: 'USD',
overheadPercentage: 0.2,
createdAt: 1000,
updatedAt: 2000,
}
}
function makeActivity(id: string, processId: string, bpmnElementId: string): Activity {
return {
id,
processId,
bpmnElementId,
name: `Tarea ${bpmnElementId}`,
type: 'task',
directCostFixed: 100,
executionTimeMinutes: 30,
assignedResources: [{ resourceId: 'res-1', utilizationPercent: 0.5 }],
}
}
function makeGateway(id: string, processId: string, bpmnElementId: string): GatewayConfig {
return {
id,
processId,
bpmnElementId,
gatewayType: 'exclusive',
branches: [
{ flowId: 'f1', targetElementId: 'taskA', probability: 0.7 },
{ flowId: 'f2', targetElementId: 'taskB', probability: 0.3 },
],
}
}
function makeResource(id: string, processId: string): Resource {
return {
id,
processId,
name: 'Analista',
type: 'role',
costPerHour: 60,
}
}
function makeSimulation(id: string, processId: string): Simulation {
const result: SimulationResult = {
totalCost: 1200,
totalDirectCost: 1000,
totalIndirectCost: 200,
totalTimeMinutes: 90,
perActivity: [],
perResource: [],
warnings: [],
}
return { id, processId, executedAt: 3000, result }
}
// ─── Setup / Teardown ─────────────────────────────────────────────────────────
beforeEach(async () => {
await db.open()
await db.transaction(
'rw',
[db.processes, db.activities, db.gateways, db.resources, db.simulations],
async () => {
await Promise.all([
db.processes.clear(),
db.activities.clear(),
db.gateways.clear(),
db.resources.clear(),
db.simulations.clear(),
])
}
)
})
afterEach(async () => {
await db.close()
})
// ─── Process ─────────────────────────────────────────────────────────────────
describe('IndexedDB / processRepo', () => {
it('guarda un proceso y lo recupera por id', async () => {
const proc = makeProcess()
await processRepo.save(proc)
const retrieved = await processRepo.getById('proc-1')
expect(retrieved).toBeDefined()
expect(retrieved!.name).toBe('Proceso de prueba')
expect(retrieved!.currency).toBe('USD')
expect(retrieved!.overheadPercentage).toBe(0.2)
})
it('getById devuelve undefined para id inexistente', async () => {
const result = await processRepo.getById('no-existe')
expect(result).toBeUndefined()
})
it('getAll devuelve todos los procesos guardados', async () => {
await processRepo.save(makeProcess('p1'))
await processRepo.save(makeProcess('p2'))
await processRepo.save(makeProcess('p3'))
const all = await processRepo.getAll()
expect(all).toHaveLength(3)
})
it('save sobrescribe (put) un proceso existente', async () => {
const proc = makeProcess()
await processRepo.save(proc)
await processRepo.save({ ...proc, name: 'Nombre actualizado', updatedAt: 9999 })
const updated = await processRepo.getById('proc-1')
expect(updated!.name).toBe('Nombre actualizado')
expect(updated!.updatedAt).toBe(9999)
})
it('delete elimina el proceso y sus entidades relacionadas en cascada', async () => {
const proc = makeProcess('proc-del')
await processRepo.save(proc)
await activityRepo.saveMany([makeActivity('a1', 'proc-del', 'task1')])
await gatewayRepo.saveMany([makeGateway('g1', 'proc-del', 'gw1')])
await resourceRepo.save(makeResource('r1', 'proc-del'))
await processRepo.delete('proc-del')
expect(await processRepo.getById('proc-del')).toBeUndefined()
expect(await activityRepo.getByProcess('proc-del')).toHaveLength(0)
expect(await gatewayRepo.getByProcess('proc-del')).toHaveLength(0)
expect(await resourceRepo.getByProcess('proc-del')).toHaveLength(0)
})
})
// ─── Activity ─────────────────────────────────────────────────────────────────
describe('IndexedDB / activityRepo', () => {
const processId = 'proc-act'
it('saveMany y getByProcess devuelven las mismas actividades', async () => {
const acts = [
makeActivity('a1', processId, 'task1'),
makeActivity('a2', processId, 'task2'),
makeActivity('a3', processId, 'task3'),
]
await activityRepo.saveMany(acts)
const retrieved = await activityRepo.getByProcess(processId)
expect(retrieved).toHaveLength(3)
const ids = retrieved.map((a) => a.id).sort()
expect(ids).toEqual(['a1', 'a2', 'a3'])
})
it('persiste assignedResources (array anidado) correctamente', async () => {
const act = makeActivity('a1', processId, 'task1')
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess(processId)
expect(retrieved.assignedResources).toHaveLength(1)
expect(retrieved.assignedResources[0].resourceId).toBe('res-1')
expect(retrieved.assignedResources[0].utilizationPercent).toBe(0.5)
})
it('no devuelve actividades de otro proceso', async () => {
await activityRepo.save(makeActivity('a-other', 'otro-proc', 'task1'))
const result = await activityRepo.getByProcess(processId)
expect(result).toHaveLength(0)
})
})
// ─── GatewayConfig ────────────────────────────────────────────────────────────
describe('IndexedDB / gatewayRepo', () => {
const processId = 'proc-gw'
it('saveMany y getByProcess devuelven la config con branches correctas', async () => {
const gw = makeGateway('g1', processId, 'gw_decision')
await gatewayRepo.saveMany([gw])
const [retrieved] = await gatewayRepo.getByProcess(processId)
expect(retrieved.bpmnElementId).toBe('gw_decision')
expect(retrieved.gatewayType).toBe('exclusive')
expect(retrieved.branches).toHaveLength(2)
expect(retrieved.branches[0].probability).toBe(0.7)
expect(retrieved.branches[1].probability).toBe(0.3)
})
it('save individual actualiza la config existente', async () => {
const gw = makeGateway('g1', processId, 'gw1')
await gatewayRepo.save(gw)
const updated: GatewayConfig = {
...gw,
branches: [
{ flowId: 'f1', targetElementId: 'taskA', probability: 0.6 },
{ flowId: 'f2', targetElementId: 'taskB', probability: 0.4 },
],
}
await gatewayRepo.save(updated)
const [retrieved] = await gatewayRepo.getByProcess(processId)
expect(retrieved.branches[0].probability).toBe(0.6)
})
})
// ─── Resource ─────────────────────────────────────────────────────────────────
describe('IndexedDB / resourceRepo', () => {
const processId = 'proc-res'
it('save y getByProcess recupera el recurso correctamente', async () => {
await resourceRepo.save(makeResource('r1', processId))
const [retrieved] = await resourceRepo.getByProcess(processId)
expect(retrieved.name).toBe('Analista')
expect(retrieved.costPerHour).toBe(60)
expect(retrieved.type).toBe('role')
})
it('delete elimina un recurso por id', async () => {
await resourceRepo.save(makeResource('r1', processId))
await resourceRepo.save(makeResource('r2', processId))
await resourceRepo.delete('r1')
const remaining = await resourceRepo.getByProcess(processId)
expect(remaining).toHaveLength(1)
expect(remaining[0].id).toBe('r2')
})
it('múltiples recursos para el mismo proceso', async () => {
const types = ['role', 'system', 'equipment'] as const
for (const [i, type] of types.entries()) {
await resourceRepo.save({ ...makeResource(`r${i}`, processId), type })
}
const all = await resourceRepo.getByProcess(processId)
expect(all).toHaveLength(3)
const typeSet = new Set(all.map((r) => r.type))
expect(typeSet.has('role')).toBe(true)
expect(typeSet.has('system')).toBe(true)
expect(typeSet.has('equipment')).toBe(true)
})
})
// ─── Simulation ───────────────────────────────────────────────────────────────
describe('IndexedDB / simulationRepo', () => {
const processId = 'proc-sim'
it('save y getLatestByProcess recuperan el snapshot de resultado', async () => {
const sim = makeSimulation('sim-1', processId)
await simulationRepo.save(sim)
const retrieved = await simulationRepo.getLatestByProcess(processId)
expect(retrieved).toBeDefined()
expect(retrieved!.result.totalCost).toBe(1200)
expect(retrieved!.result.totalDirectCost).toBe(1000)
expect(retrieved!.result.totalIndirectCost).toBe(200)
})
it('getLatestByProcess devuelve undefined si no hay simulaciones', async () => {
const result = await simulationRepo.getLatestByProcess('proc-sin-sim')
expect(result).toBeUndefined()
})
it('múltiples simulaciones: getByProcess las recupera todas', async () => {
await simulationRepo.save({ ...makeSimulation('s1', processId), executedAt: 1000 })
await simulationRepo.save({ ...makeSimulation('s2', processId), executedAt: 2000 })
await simulationRepo.save({ ...makeSimulation('s3', processId), executedAt: 3000 })
const all = await simulationRepo.getByProcess(processId)
expect(all).toHaveLength(3)
})
it('persiste warnings en el resultado (campo libre)', async () => {
const sim: Simulation = {
...makeSimulation('s-warn', processId),
result: {
...makeSimulation('s-warn', processId).result,
warnings: ['Loop detectado en taskX'],
},
}
await simulationRepo.save(sim)
const retrieved = await simulationRepo.getLatestByProcess(processId)
expect(retrieved!.result.warnings).toHaveLength(1)
expect(retrieved!.result.warnings[0]).toContain('Loop detectado')
})
})
// ─── Round-trip completo (simula el flujo de ImportPage) ─────────────────────
describe('IndexedDB / round-trip proceso completo', () => {
it('guarda proceso + actividades + gateways + recursos, luego los recupera íntegros', async () => {
const proc = makeProcess('rt-proc')
const acts = [
makeActivity('rt-a1', 'rt-proc', 'taskA'),
makeActivity('rt-a2', 'rt-proc', 'taskB'),
]
const gws = [makeGateway('rt-g1', 'rt-proc', 'gw1')]
const resources = [makeResource('rt-r1', 'rt-proc')]
await Promise.all([
processRepo.save(proc),
activityRepo.saveMany(acts),
gatewayRepo.saveMany(gws),
resourceRepo.save(resources[0]),
])
// Simular recarga de página
const [retrievedProc, retrievedActs, retrievedGws, retrievedRes] = await Promise.all([
processRepo.getById('rt-proc'),
activityRepo.getByProcess('rt-proc'),
gatewayRepo.getByProcess('rt-proc'),
resourceRepo.getByProcess('rt-proc'),
])
expect(retrievedProc!.id).toBe('rt-proc')
expect(retrievedActs).toHaveLength(2)
expect(retrievedGws).toHaveLength(1)
expect(retrievedGws[0].branches).toHaveLength(2)
expect(retrievedRes).toHaveLength(1)
expect(retrievedRes[0].costPerHour).toBe(60)
})
})

View File

@@ -0,0 +1,278 @@
/**
* Tests permanentes del motor de simulación corriendo sobre los 3 sample BPMNs.
* Protegen contra regresiones en runSimulation, la propagación de probabilidades
* y la detección de loops.
*
* Costos dummy predecibles para que las aserciones sean deterministas.
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { v4 as uuidv4 } from 'uuid'
import { parseBpmnXml, extractActivityElements, extractGatewayElements } from '@/domain/bpmn-parser'
import { runSimulation } from '@/domain/simulation'
import { bpmnTypeToGatewayType } from '@/domain/types'
import type { Activity, GatewayConfig, Resource, SimulationInput } from '@/domain/types'
function loadSample(name: string): string {
return readFileSync(resolve(__dirname, '../../public/sample-processes', name), 'utf-8')
}
// Construye un SimulationInput con costos fijos iguales para todas las actividades
function buildInput(
xml: string,
opts: {
directCostFixed?: number
executionTimeMinutes?: number
overheadPercentage?: number
gatewayProbsOverride?: Record<string, number[]>
} = {}
): SimulationInput {
const {
directCostFixed = 100,
executionTimeMinutes = 30,
overheadPercentage = 0.2,
gatewayProbsOverride = {},
} = opts
const processId = 'sim-test'
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
const gwEls = extractGatewayElements(graph)
const activities = new Map<string, Activity>(
actEls.map((el) => [
el.bpmnElementId,
{
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
name: el.name,
type: el.type,
directCostFixed,
executionTimeMinutes,
assignedResources: [],
},
])
)
const gateways = new Map<string, GatewayConfig>(
gwEls.map((el) => {
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
const n = el.outgoing.length
const probsOverride = gatewayProbsOverride[el.bpmnElementId]
return [
el.bpmnElementId,
{
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
gatewayType: gwType,
branches: el.outgoing.map((flowId, i) => ({
flowId,
targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
probability:
probsOverride?.[i] ??
(gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(6))),
})),
},
]
})
)
return {
processXml: xml,
activities,
gateways,
resources: new Map<string, Resource>(),
globalSettings: { currency: 'USD', overheadPercentage },
}
}
// ─── simple-linear.bpmn ──────────────────────────────────────────────────────
describe('simulación / simple-linear.bpmn', () => {
const xml = loadSample('simple-linear.bpmn')
it('corre sin lanzar error con costos dummy', () => {
expect(() => runSimulation(buildInput(xml))).not.toThrow()
})
it('no genera warnings (proceso sin loops ni gateways)', () => {
const result = runSimulation(buildInput(xml))
expect(result.warnings).toHaveLength(0)
})
it('costo total = 5 × 100 × 1.2 = 600 (5 tareas, prob=1, overhead=20%)', () => {
const result = runSimulation(buildInput(xml, { directCostFixed: 100, overheadPercentage: 0.2 }))
expect(result.totalDirectCost).toBe(500)
expect(result.totalIndirectCost).toBeCloseTo(100)
expect(result.totalCost).toBeCloseTo(600)
})
it('perActivity tiene 5 entradas, todas con executionProbability = 1', () => {
const result = runSimulation(buildInput(xml))
expect(result.perActivity).toHaveLength(5)
result.perActivity.forEach((a) => {
expect(a.executionProbability).toBeCloseTo(1.0)
})
})
it('perActivity ordenado descendente por expectedTotalCost', () => {
const result = runSimulation(buildInput(xml, { directCostFixed: 100 }))
for (let i = 0; i < result.perActivity.length - 1; i++) {
expect(result.perActivity[i].expectedTotalCost).toBeGreaterThanOrEqual(
result.perActivity[i + 1].expectedTotalCost
)
}
})
it('percentOfTotal suma exactamente 100%', () => {
const result = runSimulation(buildInput(xml))
const sum = result.perActivity.reduce((acc, a) => acc + a.percentOfTotal, 0)
expect(sum).toBeCloseTo(100, 1)
})
it('tiempo total = 5 × 30 min = 150 min', () => {
const result = runSimulation(buildInput(xml, { executionTimeMinutes: 30 }))
expect(result.totalTimeMinutes).toBeCloseTo(150)
})
})
// ─── medium-with-gateways.bpmn ───────────────────────────────────────────────
describe('simulación / medium-with-gateways.bpmn', () => {
const xml = loadSample('medium-with-gateways.bpmn')
it('corre sin lanzar error con costos dummy', () => {
expect(() => runSimulation(buildInput(xml))).not.toThrow()
})
it('no genera warnings (sin loops)', () => {
const result = runSimulation(buildInput(xml))
expect(result.warnings).toHaveLength(0)
})
it('perActivity tiene exactamente 11 entradas', () => {
const result = runSimulation(buildInput(xml))
expect(result.perActivity).toHaveLength(11)
})
it('actividades antes de cualquier gateway tienen prob = 1.0', () => {
const result = runSimulation(buildInput(xml))
const recv = result.perActivity.find((a) => a.bpmnElementId === 'task_recv')
const valid = result.perActivity.find((a) => a.bpmnElementId === 'task_valid')
expect(recv?.executionProbability).toBeCloseTo(1.0)
expect(valid?.executionProbability).toBeCloseTo(1.0)
})
it('gateway XOR con probs 0.7/0.3 propaga correctamente', () => {
// gw_datos: completo(idx 0)=0.7, incompleto(idx 1)=0.3
const result = runSimulation(
buildInput(xml, {
gatewayProbsOverride: { gw_datos: [0.7, 0.3] },
})
)
const score = result.perActivity.find((a) => a.bpmnElementId === 'task_score')
const pedirDocs = result.perActivity.find((a) => a.bpmnElementId === 'task_pedir_docs')
expect(score?.executionProbability).toBeCloseTo(0.7, 1)
expect(pedirDocs?.executionProbability).toBeCloseTo(0.3, 1)
})
it('gateway paralelo AND: ambas ramas tienen prob igual a la entrada', () => {
// gw_datos completo=1.0 para simplificar, gw_parallel_split AND ambas =1
const result = runSimulation(
buildInput(xml, {
gatewayProbsOverride: {
gw_datos: [1.0, 0.0], // siempre el camino completo
gw_decision: [0.5, 0.5],
},
})
)
const bureau = result.perActivity.find((a) => a.bpmnElementId === 'task_bureau')
const empleador = result.perActivity.find((a) => a.bpmnElementId === 'task_empleador')
// Ambas ramas del AND deben tener prob similar (≈ prob del score)
expect(bureau?.executionProbability).toBeGreaterThan(0)
expect(empleador?.executionProbability).toBeGreaterThan(0)
expect(Math.abs((bureau?.executionProbability ?? 0) - (empleador?.executionProbability ?? 0))).toBeLessThan(0.01)
})
it('perActivity ordenado descendente por costo', () => {
const result = runSimulation(buildInput(xml))
for (let i = 0; i < result.perActivity.length - 1; i++) {
expect(result.perActivity[i].expectedTotalCost).toBeGreaterThanOrEqual(
result.perActivity[i + 1].expectedTotalCost
)
}
})
})
// ─── complex-with-loop.bpmn ───────────────────────────────────────────────────
describe('simulación / complex-with-loop.bpmn', () => {
const xml = loadSample('complex-with-loop.bpmn')
it('corre sin lanzar error (a pesar del loop)', () => {
expect(() => runSimulation(buildInput(xml))).not.toThrow()
})
it('🚨 genera al menos un warning de loop detectado', () => {
const result = runSimulation(buildInput(xml))
expect(result.warnings.length).toBeGreaterThan(0)
const hasLoopWarning = result.warnings.some(
(w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo')
)
expect(hasLoopWarning).toBe(true)
})
it('el warning menciona el nodo "Inspección visual y funcional"', () => {
const result = runSimulation(buildInput(xml))
const loopWarning = result.warnings.find(
(w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo')
)
expect(loopWarning).toContain('Inspección visual y funcional')
})
it('resultado es válido a pesar del loop: costo > 0 y 9 actividades', () => {
const result = runSimulation(buildInput(xml, { directCostFixed: 100 }))
expect(result.totalCost).toBeGreaterThan(0)
expect(result.perActivity).toHaveLength(9)
})
it('con probabilidades reales (60% aprobado, 30% correcciones, 10% rechazo)', () => {
const result = runSimulation(
buildInput(xml, {
directCostFixed: 200,
overheadPercentage: 0.15,
gatewayProbsOverride: {
// gw_resultado: 3 salidas — aprobado 0.6, obs_menores 0.3, rechazo 0.1
gw_resultado: [0.6, 0.3, 0.1],
},
})
)
expect(result.totalCost).toBeGreaterThan(0)
expect(result.warnings.length).toBeGreaterThan(0)
// task_inspeccion tiene prob alta (es el nodo más costoso del proceso con loop)
const inspeccion = result.perActivity.find((a) => a.bpmnElementId === 'task_inspeccion')
expect(inspeccion).toBeDefined()
expect(inspeccion!.executionProbability).toBeGreaterThan(0)
// task_correcciones tiene prob = prob del camino de correcciones ≈ 0.3
const correcciones = result.perActivity.find((a) => a.bpmnElementId === 'task_correcciones')
expect(correcciones).toBeDefined()
expect(correcciones!.executionProbability).toBeCloseTo(0.3, 1)
// task_rechazo tiene prob ≈ 0.1
const rechazo = result.perActivity.find((a) => a.bpmnElementId === 'task_rechazo')
expect(rechazo).toBeDefined()
expect(rechazo!.executionProbability).toBeCloseTo(0.1, 1)
})
it('percentOfTotal suma ≈ 100%', () => {
const result = runSimulation(buildInput(xml))
if (result.perActivity.length > 0) {
const sum = result.perActivity.reduce((acc, a) => acc + a.percentOfTotal, 0)
expect(sum).toBeCloseTo(100, 0)
}
})
})

213
tests/lib/colors.test.ts Normal file
View File

@@ -0,0 +1,213 @@
import { describe, it, expect } from 'vitest'
import {
heatmapColorHex,
computeActivityT,
hasSignificantCostVariance,
activityColor,
NEUTRAL_HEATMAP_COLOR,
type ActivityCostData,
} from '@/lib/colors'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeActs(costs: number[]): ActivityCostData[] {
const total = costs.reduce((s, c) => s + c, 0) || 1
return costs.map((c) => ({
expectedTotalCost: Math.max(0, c),
percentOfTotal: (c / total) * 100,
}))
}
// ─── heatmapColorHex ─────────────────────────────────────────────────────────
describe('heatmapColorHex', () => {
it('t=0 → verde (#10b981)', () => {
expect(heatmapColorHex(0)).toBe('#10b981')
})
it('t=1 → rojo (#ef4444)', () => {
expect(heatmapColorHex(1)).toBe('#ef4444')
})
it('t=0.5 → amarillo (#f59e0b)', () => {
expect(heatmapColorHex(0.5)).toBe('#f59e0b')
})
it('t<0 → clampea a t=0 (verde)', () => {
expect(heatmapColorHex(-1)).toBe(heatmapColorHex(0))
})
it('t>1 → clampea a t=1 (rojo)', () => {
expect(heatmapColorHex(2)).toBe(heatmapColorHex(1))
})
it('devuelve string hex válido en formato #rrggbb', () => {
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
const hex = heatmapColorHex(t)
expect(hex).toMatch(/^#[0-9a-f]{6}$/)
}
})
it('colores intermedios son distintos entre sí', () => {
const colors = [0, 0.25, 0.5, 0.75, 1].map(heatmapColorHex)
const unique = new Set(colors)
expect(unique.size).toBe(5)
})
})
// ─── hasSignificantCostVariance ──────────────────────────────────────────────
describe('hasSignificantCostVariance', () => {
it('varianza cero: todos iguales → false', () => {
expect(hasSignificantCostVariance(makeActs([100, 100, 100, 100, 100]))).toBe(false)
})
it('una sola actividad → false (no hay con qué comparar)', () => {
expect(hasSignificantCostVariance(makeActs([500]))).toBe(false)
})
it('lista vacía → false', () => {
expect(hasSignificantCostVariance([])).toBe(false)
})
it('varianza < 5%: costos casi iguales → false', () => {
// rango = (103-100)/103 ≈ 2.9% < 5%
expect(hasSignificantCostVariance(makeActs([100, 101, 102, 103]))).toBe(false)
})
it('varianza > 5%: costos claramente distintos → true', () => {
// rango = (200-100)/200 = 50% > 5%
expect(hasSignificantCostVariance(makeActs([100, 200]))).toBe(true)
})
it('varianza ≈ THRESHOLD (5%): está justo en el límite', () => {
// rango = (105-100)/105 ≈ 4.76% < 5% → false
expect(hasSignificantCostVariance(makeActs([100, 105]))).toBe(false)
// rango = (111-100)/111 ≈ 9.9% > 5% → true
expect(hasSignificantCostVariance(makeActs([100, 111]))).toBe(true)
})
it('no crashea con costos negativos (defensivo)', () => {
expect(() => hasSignificantCostVariance(makeActs([-10, -5, 0]))).not.toThrow()
})
it('todos en cero → false (max=0)', () => {
expect(hasSignificantCostVariance(makeActs([0, 0, 0]))).toBe(false)
})
})
// ─── computeActivityT ────────────────────────────────────────────────────────
describe('computeActivityT — modo relativo', () => {
it('actividad con mayor % → t = 1.0', () => {
const acts = makeActs([300, 100, 50])
const t = computeActivityT(acts[0], acts, 'relative')
expect(t).toBeCloseTo(1.0)
})
it('actividad con menor costo → t < t de la más cara', () => {
const acts = makeActs([300, 100, 50])
const tBarata = computeActivityT(acts[2], acts, 'relative')
const tCara = computeActivityT(acts[0], acts, 'relative')
expect(tBarata).toBeLessThan(tCara)
})
it('t siempre está en [0, 1]', () => {
const acts = makeActs([1000, 500, 100, 10])
for (const act of acts) {
const t = computeActivityT(act, acts, 'relative')
expect(t).toBeGreaterThanOrEqual(0)
expect(t).toBeLessThanOrEqual(1)
}
})
it('lista vacía → t = 0 sin crash', () => {
const act: ActivityCostData = { expectedTotalCost: 100, percentOfTotal: 100 }
expect(computeActivityT(act, [], 'relative')).toBe(0)
})
})
describe('computeActivityT — modo absoluto', () => {
it('actividad más cara → t = 1.0', () => {
const acts = makeActs([100, 200, 300])
const t = computeActivityT(acts[2], acts, 'absolute')
expect(t).toBeCloseTo(1.0)
})
it('actividad más barata → t = 0.0', () => {
const acts = makeActs([100, 200, 300])
const t = computeActivityT(acts[0], acts, 'absolute')
expect(t).toBeCloseTo(0.0)
})
it('actividad del medio → t ≈ 0.5', () => {
const acts = makeActs([100, 200, 300])
const t = computeActivityT(acts[1], acts, 'absolute')
expect(t).toBeCloseTo(0.5)
})
it('un solo precio (max=min) → t = 0.5 (defensivo)', () => {
const acts = makeActs([100, 100])
const t = computeActivityT(acts[0], acts, 'absolute')
expect(t).toBe(0.5)
})
})
// ─── activityColor — función unificada ───────────────────────────────────────
describe('activityColor', () => {
it('SIN varianza significativa → TODOS devuelven NEUTRAL_HEATMAP_COLOR', () => {
const acts = makeActs([100, 100, 100, 100, 100])
for (const act of acts) {
expect(activityColor(act, acts, 'relative')).toBe(NEUTRAL_HEATMAP_COLOR)
expect(activityColor(act, acts, 'absolute')).toBe(NEUTRAL_HEATMAP_COLOR)
}
})
it('CON varianza real modo relativo: la más cara NO es neutra', () => {
const acts = makeActs([500, 100, 10])
const color = activityColor(acts[0], acts, 'relative')
expect(color).not.toBe(NEUTRAL_HEATMAP_COLOR)
})
it('CON varianza real modo relativo: la más cara es roja (#ef4444)', () => {
const acts = makeActs([500, 100, 10])
const colorCara = activityColor(acts[0], acts, 'relative')
expect(colorCara).toBe('#ef4444')
})
it('CON varianza real modo relativo: la más barata es verde (#10b981)', () => {
const acts = makeActs([500, 100, 10])
const colorBarata = activityColor(acts[2], acts, 'relative')
// No necesariamente exactamente verde, pero sí del espectro inferior
// La más barata tiene percentOfTotal bajo / maxPercent bajo → t bajo → color verdoso
expect(colorBarata).not.toBe('#ef4444')
})
it('CON varianza real modo absoluto: la más cara es roja', () => {
const acts = makeActs([1000, 100, 10])
const colorCara = activityColor(acts[0], acts, 'absolute')
expect(colorCara).toBe('#ef4444')
})
it('CON varianza real modo absoluto: la más barata es verde', () => {
const acts = makeActs([1000, 100, 10])
const colorBarata = activityColor(acts[2], acts, 'absolute')
expect(colorBarata).toBe('#10b981')
})
it('edge: costos negativos no crashean', () => {
const acts = makeActs([0, 0, 0])
expect(() => activityColor(acts[0], acts, 'relative')).not.toThrow()
})
it('simple-linear scenario: 5 actividades igual costo → todas neutras', () => {
// Este es el caso de use real de simple-linear.bpmn con costos fijos iguales
const acts = makeActs([100, 100, 100, 100, 100])
const colors = acts.map((a) => activityColor(a, acts, 'relative'))
expect(colors.every((c) => c === NEUTRAL_HEATMAP_COLOR)).toBe(true)
})
it('NEUTRAL_HEATMAP_COLOR es un hex válido y claramente distinto del gradiente', () => {
expect(NEUTRAL_HEATMAP_COLOR).toMatch(/^#[0-9a-f]{6}$/)
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#10b981') // no es verde
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#f59e0b') // no es amarillo
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#ef4444') // no es rojo
})
})

View File

@@ -0,0 +1,220 @@
import { describe, it, expect } from 'vitest'
import { generateCsvContent } from '@/lib/export/csv-export'
import type { Process, Simulation, Resource, SimulationResult } from '@/domain/types'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeProcess(currency: string, extras: Partial<Process> = {}): Process {
return {
id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC',
bpmnXml: '', currency, overheadPercentage: 0.2,
createdAt: 0, updatedAt: 0, ...extras,
}
}
function makeSimulation(perActivity: SimulationResult['perActivity'] = []): Simulation {
const totalDirect = perActivity.reduce((s, a) => s + a.expectedDirectCost, 0)
const totalIndirect = perActivity.reduce((s, a) => s + a.expectedIndirectCost, 0)
return {
id: 'sim1', processId: 'p1',
executedAt: new Date('2026-05-13T10:00:00Z').getTime(),
result: {
totalCost: totalDirect + totalIndirect,
totalDirectCost: totalDirect,
totalIndirectCost: totalIndirect,
totalTimeMinutes: 90,
perActivity,
perResource: [],
warnings: [],
},
}
}
const mockActivities: SimulationResult['perActivity'] = [
{
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Recibir solicitud',
expectedDirectCost: 500, expectedIndirectCost: 100, expectedTotalCost: 600,
percentOfTotal: 60, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 30, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task2', activityName: 'Análisis de crédito',
expectedDirectCost: 250, expectedIndirectCost: 50, expectedTotalCost: 300,
percentOfTotal: 30, executionProbability: 0.8, expectedExecutions: 0.8,
executionTimeMinutes: 60, resourceCostBreakdown: [],
},
{
activityId: 'a3', bpmnElementId: 'task3', activityName: 'Notificación al cliente',
expectedDirectCost: 83.33, expectedIndirectCost: 16.67, expectedTotalCost: 100,
percentOfTotal: 10, executionProbability: 0.5, expectedExecutions: 0.5,
executionTimeMinutes: 15, resourceCostBreakdown: [],
},
]
const resources: Resource[] = []
// ─── BOM UTF-8 ────────────────────────────────────────────────────────────────
describe('CSV — BOM UTF-8', () => {
it('comienza con BOM UTF-8 (\\uFEFF)', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv.charCodeAt(0)).toBe(0xFEFF)
})
it('BOM está presente para PYG también', () => {
const csv = generateCsvContent({ process: makeProcess('PYG'), simulation: makeSimulation(mockActivities), resources })
expect(csv.charCodeAt(0)).toBe(0xFEFF)
})
})
// ─── Separadores ──────────────────────────────────────────────────────────────
describe('CSV — separadores', () => {
it('USD: separador de columnas es coma ","', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
// Los headers deben estar separados por coma
const headerLine = csv.split('\r\n').find((l) => l.includes('Actividad'))!
expect(headerLine).toContain(',')
expect(headerLine).not.toContain(';')
})
it('PYG: separador de columnas es punto y coma ";"', () => {
const csv = generateCsvContent({ process: makeProcess('PYG'), simulation: makeSimulation(mockActivities), resources })
const headerLine = csv.split('\r\n').find((l) => l.includes('Actividad'))!
expect(headerLine).toContain(';')
expect(headerLine).not.toContain(',"Actividad"')
})
it('BRL: separador es punto y coma', () => {
const csv = generateCsvContent({ process: makeProcess('BRL'), simulation: makeSimulation(mockActivities), resources })
const headerLine = csv.split('\r\n').find((l) => l.includes('Actividad'))!
expect(headerLine).toContain(';')
})
it('EUR: separador es punto y coma', () => {
const csv = generateCsvContent({ process: makeProcess('EUR'), simulation: makeSimulation(mockActivities), resources })
const headerLine = csv.split('\r\n').find((l) => l.includes('Actividad'))!
expect(headerLine).toContain(';')
})
})
// ─── Separadores decimales ─────────────────────────────────────────────────────
describe('CSV — separadores decimales', () => {
it('USD: decimal es punto "." → "500.00"', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('500.00')
})
it('PYG: decimal es coma "," → "500,00"', () => {
const csv = generateCsvContent({ process: makeProcess('PYG'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('500,00')
})
it('BRL: decimal es coma', () => {
const csv = generateCsvContent({ process: makeProcess('BRL'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('500,00')
})
})
// ─── Headers ──────────────────────────────────────────────────────────────────
describe('CSV — headers', () => {
it('contiene "Actividad" en los headers', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('Actividad')
})
it('header de costo incluye la moneda: "Costo total (USD)"', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('Costo total (USD)')
})
it('header de costo incluye la moneda: "Costo total (PYG)"', () => {
const csv = generateCsvContent({ process: makeProcess('PYG'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('Costo total (PYG)')
})
it('contiene todos los headers requeridos', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('ID BPMN')
expect(csv).toContain('Tiempo (min)')
expect(csv).toContain('% del total')
expect(csv).toContain('Ejecuciones esperadas')
expect(csv).toContain('Recursos asignados')
})
})
// ─── Cantidad de filas ────────────────────────────────────────────────────────
describe('CSV — cantidad de filas', () => {
it('tiene exactamente N filas de datos + header + metadata', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
// Quitar BOM antes de procesar para no confundir el filtro de comentarios
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const lines = csvNoBom.split('\r\n').filter((l) => l.trim() && !l.startsWith('#'))
// 1 header + N actividades
expect(lines).toHaveLength(mockActivities.length + 1)
})
it('0 actividades → solo header', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation([]), resources })
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const lines = csvNoBom.split('\r\n').filter((l) => l.trim() && !l.startsWith('#'))
expect(lines).toHaveLength(1) // solo el header
})
})
// ─── Metadata de cabecera ─────────────────────────────────────────────────────
describe('CSV — metadata', () => {
it('la metadata comienza con "# Proceso:"', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('# Proceso: Proceso de Ventas')
})
it('la metadata contiene el nombre del cliente', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('# Cliente: Empresa ABC')
})
it('la metadata contiene el costo total', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('# Costo total (USD)')
})
})
// ─── Caracteres especiales / UTF-8 ───────────────────────────────────────────
describe('CSV — UTF-8 y caracteres especiales', () => {
it('nombres con acentos se preservan correctamente', () => {
const activitiesWithAccents: SimulationResult['perActivity'] = [{
activityId: 'a1', bpmnElementId: 't1',
activityName: 'Revisión y Análisis Crítico',
expectedDirectCost: 100, expectedIndirectCost: 20, expectedTotalCost: 120,
percentOfTotal: 100, executionProbability: 1, expectedExecutions: 1,
executionTimeMinutes: 30, resourceCostBreakdown: [],
}]
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(activitiesWithAccents), resources })
expect(csv).toContain('Revisión y Análisis Crítico')
})
it('nombre del proceso con ñ se preserva', () => {
const process = makeProcess('USD', { name: 'Gestión de la Compañía' })
const csv = generateCsvContent({ process, simulation: makeSimulation([]), resources })
expect(csv).toContain('Gestión de la Compañía')
})
it('actividad con nombre en blanco no rompe el CSV', () => {
const activitiesBlank: SimulationResult['perActivity'] = [{
activityId: 'a1', bpmnElementId: 't1', activityName: '',
expectedDirectCost: 0, expectedIndirectCost: 0, expectedTotalCost: 0,
percentOfTotal: 0, executionProbability: 1, expectedExecutions: 1,
executionTimeMinutes: 0, resourceCostBreakdown: [],
}]
expect(() => generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(activitiesBlank), resources })).not.toThrow()
})
})
// ─── Recursos vacíos ──────────────────────────────────────────────────────────
describe('CSV — recursos vacíos', () => {
it('actividad sin recursos → celda de recursos vacía (no rompe)', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources: [] })
// No debe crashear y el contenido es válido
expect(csv.length).toBeGreaterThan(0)
expect(csv).toContain('Recibir solicitud')
})
})

View File

@@ -0,0 +1,88 @@
/**
* Medición de tamaños de CSV para los 3 sample BPMNs.
* Este test calcula el tamaño en bytes del CSV generado para cada BPMN.
*/
import { describe, it } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { v4 as uuidv4 } from 'uuid'
import { parseBpmnXml, extractActivityElements, extractGatewayElements } from '@/domain/bpmn-parser'
import { runSimulation } from '@/domain/simulation'
import { generateCsvContent } from '@/lib/export/csv-export'
import { bpmnTypeToGatewayType } from '@/domain/types'
import type { Activity, GatewayConfig, SimulationInput, Process, Simulation } from '@/domain/types'
function loadBpmn(name: string): string {
return readFileSync(resolve(__dirname, '../../../public/sample-processes', name), 'utf-8')
}
function buildInput(xml: string, directCost: number): SimulationInput {
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
const gwEls = extractGatewayElements(graph)
const processId = 'measure-proc'
const activities = new Map<string, Activity>(
actEls.map((el) => [el.bpmnElementId, {
id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, name: el.name,
type: el.type, directCostFixed: directCost, executionTimeMinutes: 30, assignedResources: [],
}])
)
const gateways = new Map<string, GatewayConfig>(
gwEls.map((el) => {
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
return [el.bpmnElementId, {
id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, gatewayType: gwType,
branches: el.outgoing.map((flowId) => ({
flowId, targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
probability: gwType === 'parallel' ? 1.0 : 1 / el.outgoing.length,
})),
}]
})
)
return {
processXml: xml, activities, gateways, resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
}
}
describe('Medición de tamaños de CSV — 3 sample BPMNs', () => {
const BPMNS = [
{ file: 'simple-linear.bpmn', name: 'Proceso Lineal Simple', client: 'Demo' },
{ file: 'medium-with-gateways.bpmn', name: 'Aprobación de Crédito', client: 'Banco ABC' },
{ file: 'complex-with-loop.bpmn', name: 'Control de Calidad', client: 'Empresa XYZ' },
]
for (const bpmn of BPMNS) {
it(`${bpmn.file}: mide tamaño del CSV`, () => {
const xml = loadBpmn(bpmn.file)
const result = runSimulation(buildInput(xml, 1000))
const process: Process = {
id: 'p1', name: bpmn.name, clientName: bpmn.client,
bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
createdAt: 0, updatedAt: 0,
}
const simulation: Simulation = {
id: 's1', processId: 'p1',
executedAt: new Date('2026-05-13T12:00:00Z').getTime(),
result,
}
const csv = generateCsvContent({ process, simulation, resources: [] })
const bytes = new TextEncoder().encode(csv).length
console.log(`\n📊 ${bpmn.file}`)
console.log(` Actividades: ${result.perActivity.length}`)
console.log(` CSV size: ${bytes} bytes (${(bytes / 1024).toFixed(1)} KB)`)
console.log(` CSV líneas de datos: ${result.perActivity.length} + 1 header + 7 meta`)
console.log(` Costo total: $${result.totalCost.toFixed(2)}`)
// El CSV no debe ser vacío
expect(bytes).toBeGreaterThan(100)
})
}
})

View File

@@ -0,0 +1,166 @@
/**
* Smoke tests del export PDF.
* jsPDF + autotable se mockean porque requieren canvas/DOM completo.
* Se verifica: llamadas al builder, metadata, nombre de archivo, no-throw.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ─── Mock de jsPDF ────────────────────────────────────────────────────────────
const mockDoc = {
setProperties: vi.fn(),
setFont: vi.fn(),
setFontSize: vi.fn(),
setTextColor: vi.fn(),
setFillColor: vi.fn(),
setDrawColor: vi.fn(),
setLineWidth: vi.fn(),
text: vi.fn(),
rect: vi.fn(),
roundedRect: vi.fn(),
line: vi.fn(),
addImage: vi.fn(),
addPage: vi.fn(),
setPage: vi.fn(),
save: vi.fn(),
splitTextToSize: vi.fn().mockImplementation((text: string) => [text]),
internal: { getNumberOfPages: vi.fn().mockReturnValue(3) },
}
// Arrow functions no pueden ser constructoras — usar function() regular
vi.mock('jspdf', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function MockJsPDF(this: any) { return mockDoc }
return { jsPDF: MockJsPDF }
})
vi.mock('jspdf-autotable', () => ({
default: vi.fn(),
}))
// ─── Fixtures ─────────────────────────────────────────────────────────────────
import type { Process, Simulation } from '@/domain/types'
const mockProcess: Process = {
id: 'p1', name: 'Proceso de Aprobación de Crédito',
clientName: 'Banco XYZ',
bpmnXml: '', currency: 'USD',
overheadPercentage: 0.2,
createdAt: 0, updatedAt: 0,
}
const mockSimulation: Simulation = {
id: 'sim1', processId: 'p1',
executedAt: new Date('2026-05-13T10:00:00Z').getTime(),
result: {
totalCost: 1200,
totalDirectCost: 1000,
totalIndirectCost: 200,
totalTimeMinutes: 90,
perActivity: [
{
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Recibir solicitud',
expectedDirectCost: 600, expectedIndirectCost: 120, expectedTotalCost: 720,
percentOfTotal: 60, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 30, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task2', activityName: 'Procesar',
expectedDirectCost: 400, expectedIndirectCost: 80, expectedTotalCost: 480,
percentOfTotal: 40, executionProbability: 0.8, expectedExecutions: 0.8,
executionTimeMinutes: 60, resourceCostBreakdown: [],
},
],
perResource: [],
warnings: [],
},
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('exportToPdf — smoke tests', () => {
beforeEach(() => { vi.clearAllMocks() })
it('no lanza error con parámetros válidos y heatmap null', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await expect(exportToPdf({
process: mockProcess,
simulation: mockSimulation,
resources: [],
heatmapImageData: null,
})).resolves.not.toThrow()
})
it('llama a doc.addPage al menos una vez (estructura multi-página)', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
// El PDF tiene al menos 2 páginas (análisis + tabla)
expect(mockDoc.addPage).toHaveBeenCalled()
})
it('llama a setProperties con metadata correcta', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
expect(mockDoc.setProperties).toHaveBeenCalledWith(expect.objectContaining({
title: expect.stringContaining('Proceso de Aprobación de Crédito'),
author: 'Banco XYZ',
subject: 'Simulación de proceso BPMN',
}))
})
it('llama a doc.save con nombre de archivo correcto', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
const saveCall = mockDoc.save.mock.calls[0][0] as string
expect(saveCall).toMatch(/proceso-de-aprobacion-de-credito/)
expect(saveCall).toMatch(/banco-xyz/)
expect(saveCall).toMatch(/\.pdf$/)
})
it('NO llama a addImage cuando heatmapImageData es null', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
expect(mockDoc.addImage).not.toHaveBeenCalled()
})
it('llama a addImage cuando heatmapImageData está disponible', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
const fakeDataUrl = 'data:image/jpeg;base64,/9j/fake'
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: fakeDataUrl })
expect(mockDoc.addImage).toHaveBeenCalledWith(fakeDataUrl, 'JPEG', expect.any(Number), expect.any(Number), expect.any(Number), expect.any(Number))
})
it('llama a addPageNumbers (setPage para el footer)', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
// addPageNumbers itera setPage para cada página
expect(mockDoc.setPage).toHaveBeenCalled()
})
it('llama a autoTable con head que contiene la moneda', async () => {
const autoTable = (await import('jspdf-autotable')).default as ReturnType<typeof vi.fn>
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
expect(autoTable).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
head: expect.arrayContaining([
expect.arrayContaining([
expect.objectContaining({ content: expect.stringContaining('USD') }),
]),
]),
})
)
})
it('con warnings en el resultado: no lanza error', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
const simWithWarnings = {
...mockSimulation,
result: { ...mockSimulation.result, warnings: ['Loop detectado en task1'] },
}
await expect(exportToPdf({
process: mockProcess, simulation: simWithWarnings, resources: [], heatmapImageData: null,
})).resolves.not.toThrow()
})
})

View File

@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest'
import { slugify, buildFileName } from '@/lib/export/slug'
describe('slugify', () => {
it('"Proceso de Compras" → "proceso-de-compras"', () => {
expect(slugify('Proceso de Compras')).toBe('proceso-de-compras')
})
it('"Análisis Año 2025" → "analisis-ano-2025"', () => {
expect(slugify('Análisis Año 2025')).toBe('analisis-ano-2025')
})
it('elimina la ñ → n', () => {
expect(slugify('Gestión de Compañía')).toBe('gestion-de-compania')
})
it('elimina acentos (á é í ó ú ü)', () => {
expect(slugify('Ú ltimo Análisis Crítico')).toBe('u-ltimo-analisis-critico')
})
it('caracteres especiales → guiones', () => {
expect(slugify('Proceso #1 / Sub-proceso (test)')).toBe('proceso-1-sub-proceso-test')
})
it('múltiples espacios → un solo guión', () => {
expect(slugify('Proceso de ventas')).toBe('proceso-de-ventas')
})
it('string vacío → string vacío', () => {
expect(slugify('')).toBe('')
})
it('solo números → se mantienen', () => {
expect(slugify('2025')).toBe('2025')
})
it('ya en minúsculas sin acentos → sin cambios relevantes', () => {
expect(slugify('proceso-lineal')).toBe('proceso-lineal')
})
it('limita a 60 caracteres', () => {
const long = 'a'.repeat(100)
expect(slugify(long)).toHaveLength(60)
})
})
describe('buildFileName', () => {
// new Date(año, mes0, día) → fecha local sin ambigüedad de timezone
const date = new Date(2026, 4, 13) // 13 de mayo de 2026, local time
it('genera filename con proceso + cliente + fecha', () => {
const name = buildFileName('Proceso de Ventas', 'Banco ABC', date, 'pdf')
expect(name).toBe('proceso-de-ventas_banco-abc_20260513.pdf')
})
it('omite cliente si está vacío', () => {
const name = buildFileName('Proceso de Ventas', '', date, 'csv')
expect(name).toBe('proceso-de-ventas_20260513.csv')
})
it('omite cliente si es undefined', () => {
const name = buildFileName('Proceso', undefined, date, 'pdf')
expect(name).toBe('proceso_20260513.pdf')
})
it('extensión csv correcta', () => {
const name = buildFileName('test', 'cliente', date, 'csv')
expect(name).toMatch(/\.csv$/)
})
it('extensión pdf correcta', () => {
const name = buildFileName('test', 'cliente', date, 'pdf')
expect(name).toMatch(/\.pdf$/)
})
it('nombre con caracteres especiales queda limpio (sin #, paréntesis)', () => {
const name = buildFileName('Análisis #1', 'Empresa S.A.', date, 'csv')
// Verificar solo el slug (sin la extensión que tiene un punto legítimo)
const slug = name.replace(/\.\w+$/, '')
expect(slug).not.toMatch(/[#()]/)
expect(name).toMatch(/analisis/)
})
})

211
tests/lib/format.test.ts Normal file
View File

@@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest'
import { formatCurrency, formatPercent, formatMinutes, formatSimulationTimestamp } from '@/lib/format'
// ─── formatCurrency ───────────────────────────────────────────────────────────
describe('formatCurrency', () => {
// USD
it('USD: formatea 0 correctamente', () => {
const result = formatCurrency(0, 'USD')
expect(result).toMatch(/0/)
expect(result).toMatch(/\$|USD/)
})
it('USD: formatea 1 con 2 decimales', () => {
const result = formatCurrency(1, 'USD')
expect(result).toMatch(/1[.,]00/)
})
it('USD: formatea 1000 con separador de miles', () => {
const result = formatCurrency(1000, 'USD')
// Debe tener separador (coma o punto) antes de los tres ceros
expect(result).toMatch(/1[.,]000/)
})
it('USD: formatea 1234567 con separadores', () => {
const result = formatCurrency(1234567, 'USD')
expect(result).toMatch(/1/)
expect(result).toMatch(/234/)
expect(result).toMatch(/567/)
})
it('USD: formatea decimales correctamente', () => {
const result = formatCurrency(1234.56, 'USD')
expect(result).toMatch(/1[.,]234/)
expect(result).toMatch(/56/)
})
// PYG — el guaraní no tiene decimales centavos en la práctica
it('PYG: devuelve una cadena con el valor', () => {
const result = formatCurrency(1000000, 'PYG')
expect(result).toBeTruthy()
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
it('PYG: 0 formatea sin crash', () => {
expect(() => formatCurrency(0, 'PYG')).not.toThrow()
})
it('PYG: número grande formatea sin crash', () => {
expect(() => formatCurrency(1_234_567_890, 'PYG')).not.toThrow()
const result = formatCurrency(1_234_567_890, 'PYG')
expect(result).toMatch(/1/)
})
// BRL
it('BRL: formatea con símbolo correcto', () => {
const result = formatCurrency(1000, 'BRL')
expect(result).toBeTruthy()
// El símbolo R$ puede aparecer o la abreviatura BRL
expect(result).toMatch(/1/)
})
// EUR
it('EUR: formatea 1234.56 sin crash', () => {
expect(() => formatCurrency(1234.56, 'EUR')).not.toThrow()
const result = formatCurrency(1234.56, 'EUR')
expect(result).toMatch(/1/)
})
// Negativos — el reporte no debe tener valores negativos, pero la función no debe crashear
it('No crashea con valores negativos', () => {
expect(() => formatCurrency(-100, 'USD')).not.toThrow()
const result = formatCurrency(-100, 'USD')
expect(result).toMatch(/-|100/)
})
// Múltiples monedas producen outputs distintos
it('USD y PYG producen outputs distintos para el mismo número', () => {
const usd = formatCurrency(1000, 'USD')
const pyg = formatCurrency(1000, 'PYG')
expect(usd).not.toBe(pyg)
})
})
// ─── formatMinutes ────────────────────────────────────────────────────────────
describe('formatMinutes', () => {
it('5 min → "5 min"', () => {
expect(formatMinutes(5)).toBe('5 min')
})
it('30 min → "30 min"', () => {
expect(formatMinutes(30)).toBe('30 min')
})
it('59 min → "59 min" (aún no llega a 1h)', () => {
expect(formatMinutes(59)).toBe('59 min')
})
it('60 min → "1h" (exactamente 1 hora)', () => {
expect(formatMinutes(60)).toBe('1h')
})
it('90 min → "1h 30min"', () => {
expect(formatMinutes(90)).toBe('1h 30min')
})
it('125 min → "2h 5min"', () => {
expect(formatMinutes(125)).toBe('2h 5min')
})
it('120 min → "2h" (horas exactas, sin fracción)', () => {
expect(formatMinutes(120)).toBe('2h')
})
it('1440 min → "24h" (un día completo)', () => {
expect(formatMinutes(1440)).toBe('24h')
})
it('0 min → "0 min"', () => {
expect(formatMinutes(0)).toBe('0 min')
})
it('No crashea con valores decimales', () => {
expect(() => formatMinutes(90.7)).not.toThrow()
})
})
// ─── formatPercent ────────────────────────────────────────────────────────────
// Nota: el locale es-PY usa coma como separador decimal → "23,4%" no "23.4%".
// Los tests verifican estructura (contiene el valor y el símbolo) sin asumir separador.
describe('formatPercent', () => {
it('23.4 → contiene "23" y "4" y "%" con 1 decimal', () => {
const result = formatPercent(23.4)
expect(result).toContain('23')
expect(result).toContain('4')
expect(result).toContain('%')
})
it('100 → contiene "100" y "%" con 1 decimal', () => {
const result = formatPercent(100)
expect(result).toContain('100')
expect(result).toContain('%')
})
it('0 → contiene "0" y "%" sin crash', () => {
const result = formatPercent(0)
expect(result).toContain('0')
expect(result).toContain('%')
})
it('el locale es-PY usa coma decimal: 23.4 → "23,4%"', () => {
// Documenta el comportamiento real del locale configurado
expect(formatPercent(23.4)).toBe('23,4%')
})
it('100% con 0 decimales → "100%"', () => {
expect(formatPercent(100, 0)).toBe('100%')
})
it('20 con 0 decimales → "20%"', () => {
expect(formatPercent(20, 0)).toBe('20%')
})
})
// ─── formatSimulationTimestamp ────────────────────────────────────────────────
describe('formatSimulationTimestamp', () => {
// Timestamp fijo para reproducibilidad: 13 de mayo de 2026, 09:45 UTC-4 (PY hora de verano)
// Usamos una fecha conocida y verificamos la estructura del output sin depender de TZ del CI.
const KNOWN_TS = new Date('2026-05-13T13:45:00Z').getTime() // UTC
it('devuelve objeto con date y time strings no vacíos', () => {
const { date, time } = formatSimulationTimestamp(KNOWN_TS)
expect(date.length).toBeGreaterThan(0)
expect(time.length).toBeGreaterThan(0)
})
it('date contiene el año 2026', () => {
const { date } = formatSimulationTimestamp(KNOWN_TS)
expect(date).toContain('2026')
})
it('date contiene el número 13 (día)', () => {
const { date } = formatSimulationTimestamp(KNOWN_TS)
expect(date).toContain('13')
})
it('date contiene "mayo" (nombre del mes en es-PY)', () => {
const { date } = formatSimulationTimestamp(KNOWN_TS)
expect(date).toMatch(/mayo/i)
})
it('time tiene formato HH:mm (dos pares de dígitos separados por ":")', () => {
const { time } = formatSimulationTimestamp(KNOWN_TS)
expect(time).toMatch(/\d{1,2}:\d{2}/)
})
it('no crashea con timestamp 0 (epoch)', () => {
expect(() => formatSimulationTimestamp(0)).not.toThrow()
const { date, time } = formatSimulationTimestamp(0)
expect(date.length).toBeGreaterThan(0)
expect(time.length).toBeGreaterThan(0)
})
it('timestamps distintos producen salidas distintas', () => {
const ts1 = new Date('2026-01-01T10:00:00Z').getTime()
const ts2 = new Date('2026-06-15T18:30:00Z').getTime()
const r1 = formatSimulationTimestamp(ts1)
const r2 = formatSimulationTimestamp(ts2)
expect(r1.date).not.toBe(r2.date)
})
})

8
tests/setup.ts Normal file
View File

@@ -0,0 +1,8 @@
// Parchear IndexedDB antes de que Dexie intente abrirla
import 'fake-indexeddb/auto'
import '@testing-library/jest-dom'
// jsdom no implementa scrollIntoView — mock para que no rompa tests de tabla
if (typeof HTMLElement !== 'undefined') {
HTMLElement.prototype.scrollIntoView = () => {}
}

32
tsconfig.app.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client", "vitest/globals"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Strict */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "tests"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

34
vite.config.ts Normal file
View File

@@ -0,0 +1,34 @@
import path from 'path'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
rollupOptions: {
output: {
manualChunks: (id: string) => {
if (id.includes('node_modules/bpmn-js') || id.includes('node_modules/diagram-js')) return 'vendor-bpmn'
if (id.includes('node_modules/echarts') || id.includes('node_modules/zrender')) return 'vendor-charts'
if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) return 'vendor-react'
if (id.includes('node_modules/@tanstack')) return 'vendor-router'
if (id.includes('node_modules/@radix-ui')) return 'vendor-ui'
},
},
},
},
optimizeDeps: {
include: ['bpmn-js'],
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
exclude: ['**/node_modules/**', '**/dist/**', 'tests/e2e/**'],
},
})