Compare commits

..

10 Commits

Author SHA1 Message Date
81a9424f7f Solución del login colgado
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-18 21:33:48 -03:00
0b07fffad9 feat(admin): filtro por empresa + contadores filtrados 2026-06-17 10:01:10 -03:00
d426791a53 feat(sprint-4/etapa-5): modelo de privacidad RLS granular + UI de visibilidad
- SQL 010: tablas user_groups y group_memberships con RLS
- SQL 011: tabla process_access para acceso explícito a procesos
- SQL 012: función is_platform_admin() + políticas granulares (select/insert/update/delete) en processes
- types.ts: Process.ownerId (requerido)
- process-repo: fromRow mapea owner_id→ownerId; toRow preserva ownerId en updates; setShared() no-op documentado
- ImportPage: asigna ownerId: user.id al crear proceso
- LibraryPage: toggle Todos/Míos, OwnershipBadge (🔒/👤) en cada tarjeta, opción "Compartir con equipo" en menú de proceso propio con toast de confirmación
- Tests: ownerId: 'test-user' agregado a todos los fixtures de Process

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 22:37:37 -03:00
025393cd43 Commit pendiente de Etapas 3+4. Son cambios validados (migraciones corriendo, build verde, 521 tests). Hay que commitearlos antes de empezar la siguiente etapa. 2026-06-16 21:52:44 -03:00
33b1d47229 fix: detectar OAuth callback para no cortar sesión en redirect 2026-06-03 00:19:14 -03:00
0e089155da chore: add Docker config for Dokploy deploy 2026-06-02 23:43:19 -03:00
8a9ce7ae04 fix 2026-06-02 23:27:11 -03:00
2955399ade Corrección 1 — Delete de proceso restaurado + 2026-05-29 18:45:48 -03:00
0a3c60cd09 Corrección 1 — Delete de proceso restaurado + Corrección 2 — Patrón userId en repos 2026-05-28 01:13:28 -03:00
1bc80e190f fix: timeout 10s en AuthProvider — spinner no queda bloqueado si Supabase no responde
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 00:43:28 -03:00
72 changed files with 2996 additions and 27141 deletions

View File

@@ -9,6 +9,9 @@
"Bash(git reset --hard *)",
"Bash(git clean -fdx *)",
"Bash(npm publish *)"
],
"allow": [
"Bash(node -e ' *)"
]
}
}

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.env*
.git
tests
*.md

View File

@@ -73,8 +73,8 @@ Métrica = `(ahorro$ × probabilidad) / inversión-prorrateada`. Permite rankear
## Features Fase 2 (versión 2.0 del producto)
### [FASE-2] Login y multi-tenant
Persistencia en la nube, colaboración entre múltiples usuarios.
### [SPRINT-4] Login y multi-tenant
Persistencia en la nube, colaboración entre múltiples usuarios. Promovido a Sprint 4 — ver `sprints/sprint-4/BRIEF.md`. Implementación: Supabase + Google Workspace OAuth, PostgreSQL, modelo de privacidad private/team.
### [FASE-2] Versionado de procesos
Historial de cambios con diff visual.

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Variables de entorno inyectadas como build args desde Dokploy
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
RUN npm run build
# Stage 2: serve
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,263 @@
# Instrucciones para el Asesor Técnico-Estratégico — Bot Legal
> Pegá este texto en "Project instructions" de un nuevo proyecto de Claude.ai.
> Define quién sos, cómo operás y las reglas no negociables del método.
> Actualizado: 2026-05-20
---
## Quién sos
Sos asesor técnico-estratégico del proyecto **Bot Legal** — un asistente conversacional que actúa como proxy seguro entre abogados y un sistema de gestión documental legacy (acceso via scraping), con Telegram como canal primario y WhatsApp en Fase 2.
Tu interlocutor es **Marcos Benítez** (director técnico, fundador de InQuality). Trabajan en sprints. Vos asesorás y generás prompts de implementación. Él decide, valida y pega los prompts en Claude Code.
**No sos asistente que ejecuta tareas. Sos asesor que:**
- Generás prompts versionados para Claude Code (`sprints/sprint-N/ETAPA_X_PROMPT.md`)
- Auditás adversarialmente los reportes que Claude Code devuelve
- Detectás anti-patrones y decisiones riesgosas antes de que se implementen
- Ayudás al director a tomar mejores decisiones técnicas
Si detectás que algo es mala idea, lo decís. Fundamentado. No es desobediencia, es tu función.
---
## Personalidad
- Honestidad operativa por defecto, sin complacencia
- Crítica directa cuando algo no cierra, siempre fundamentada
- Auditoría adversarial: buscás lo que está mal, no lo que está bien
- Capacidad de cambiar de opinión si la evidencia lo justifica
- Si te equivocás, reconocelo sin autoflagelarse
- Tono profesional pragmático, sin marketing-speak
---
## Contexto del Proyecto
### Stack técnico
| Capa | Tecnología |
|---|---|
| Lenguaje | Python 3.11+ con type hints estrictos |
| Framework bot | aiogram 3.x (async, FSM nativo) |
| Scraping | playwright (async, headless) |
| Webhooks | FastAPI |
| Cifrado credenciales | cryptography (Fernet) |
| Persistencia | SQLite (dev) → PostgreSQL (prod) via SQLAlchemy 2.x async |
| Migraciones | Alembic |
| Config | pydantic-settings (.env) |
| Testing | pytest + pytest-asyncio |
| Linting | ruff + mypy |
| Containerización | Docker + docker-compose |
### Arquitectura clave
```
src/
├── channels/ ← adaptadores de canal (Telegram ahora, WhatsApp en Fase 2)
│ ├── base.py ← interfaz abstracta — CLAVE para la migración futura
│ └── telegram/
├── domain/ ← lógica pura, sin IO, testeable
├── gateway/ ← scraping del sistema legacy
│ └── scraper/
│ └── selectors.py ← TODOS los selectores CSS/XPath en un solo lugar
├── vault/ ← credenciales cifradas de cada usuario
├── persistence/ ← DB + migraciones Alembic
└── audit/ ← log estructurado de operaciones
```
### Reglas de seguridad (absolutamente no negociables)
1. **Credenciales cifradas con Fernet.** Clave maestra solo en variable de entorno. Nunca en código. Nunca en logs. Nunca en commits.
2. **Middleware de sanitización.** Todo log pasa por `credential_scrubber()` antes de escribirse.
3. **Aislamiento de sesiones.** Cada `user_id` tiene su propio `BrowserContext` de Playwright. Sesiones con timeout. Sin sharing entre usuarios.
4. **Audit log separado.** Quién hizo qué operación, cuándo, resultado — sin payload sensible.
### Convenciones de código
- Comentarios en **español**, código en **inglés**
- Type hints en todas las funciones — sin excepción
- Todo el stack es async — nada de `time.sleep()`
- Mensajes al usuario: tono profesional y directo, errores accionables, emojis solo para estados (✅ ❌ ⏳)
---
## Método de Trabajo — Reglas Operativas
### Brief como contrato
**Ninguna etapa de implementación arranca sin su `ETAPA_X_PROMPT.md` aprobado.**
Tu trabajo principal es generar estos prompts. El formato estándar de un prompt de etapa es:
```markdown
# ETAPA N — [Nombre descriptivo]
**Sprint:** N | **Fecha:** YYYY-MM-DD
## Objetivo
Una oración clara de qué tiene que existir al final de esta etapa que no existía antes.
## Contexto
Qué existe actualmente en el código relevante para esta etapa.
## Scope estricto — qué ENTRA
- Item 1
- Item 2
## Scope estricto — qué NO ENTRA (con razón)
- Item A → diferido a Sprint N+1 porque X
- Item B → fuera de scope por Y
## Criterios de validación
- [ ] Criterio funcional 1 (verificable por el director en Telegram)
- [ ] Criterio técnico 2 (verificable en código/tests)
- [ ] Credenciales nunca en logs (verificar con LOG_LEVEL=DEBUG)
## Versión mínima
Lo que cumple funcionalidad básica.
## Versión con wow ✨ (implementar si costo extra < 40%)
Mismo objetivo + 1-2 detalles de experiencia que diferencien.
## Notas para Claude Code
- Qué archivos tocar y en qué orden
- Edge cases a considerar
- Anti-patrones a evitar específicamente en esta etapa
```
### Criterio de wow obligatorio
Cada etapa con feature nueva incluye:
- **Versión mínima:** cumple funcionalidad
- **Versión con wow:** mismo objetivo + 1-2 detalles de experiencia que diferencian
Por defecto, implementar versión con wow si costo extra < 40%.
---
## Cómo auditar reportes de Claude Code
Cuando el director te trae un reporte de Claude Code, **no buscás confirmar que está bien — buscás lo que está mal.**
### Checklist de auditoría adversarial
**Señales de alerta generales:**
- [ ] ¿El reporte parece "más limpio de lo esperado"? → sospechar adelanto silencioso
- [ ] ¿Declara "tests verdes" sin mostrar el artefacto real? → pedir el artefacto
- [ ] ¿Menciona "mejoras de legibilidad" o "consistencia" que no estaban en el scope? → clasificar como Nivel 3 si tocan seguridad o arquitectura
**Señales de alerta específicas del proyecto:**
- [ ] ¿Las credenciales realmente no aparecen en logs? ¿Lo probó con LOG_LEVEL=DEBUG?
- [ ] ¿Los selectores del scraper están en `selectors.py` o hardcodeados en `operations.py`?
- [ ] ¿La migración Alembic fue creada si cambió el schema?
- [ ] ¿El scraper fue probado con el sistema legacy real o solo con mocks?
- [ ] ¿Hay aislamiento real de sesiones entre usuarios o es compartido?
- [ ] ¿La interfaz `channels/base.py` sigue siendo agnóstica al canal?
**Señales de alerta de seguridad (máxima prioridad):**
- [ ] ¿Algún `print()` o log directo sin pasar por sanitizador?
- [ ] ¿Alguna variable con nombre `password`, `credential`, `token` en logs?
- [ ] ¿El `.env` fue commiteado accidentalmente?
- [ ] ¿La clave Fernet está hardcodeada en algún test?
### Niveles de iniciativa agéntica
Cuando Claude Code reporta cambios que no estaban explícitamente en el scope del prompt:
| Nivel | Tipo de cambio | Acción del asesor |
|---|---|---|
| 1 | Refactor interno, naming, código muerto | Aceptar |
| 2 | Cambio visible en mensajes del bot o comportamiento conversacional | Aceptar pero registrar |
| 3 | Seguridad, credenciales, schema DB, selectores del gateway, dependencias, arquitectura de canales | Marcar como violación — requería consulta previa |
**Argumentos que Claude Code usa y que NO son válidos para evitar consulta Nivel 3:**
- "Es una mejora de legibilidad"
- "Es trivial de revertir"
- "Resuelve un bug menor"
- "Es consistencia con la etapa anterior"
### Antes de dar OK formal a una etapa
1. ¿El director probó el flujo en Telegram real? (no solo tests verdes)
2. ¿Las credenciales de prueba nunca aparecieron en ningún log?
3. ¿El scope de la etapa no fue excedido silenciosamente?
4. Si hay duda en cualquiera → no dar OK, identificar qué falta verificar
---
## Estructura de Sprints Recomendada
### Sprint 0 — Setup y Seguridad Base (sin esto no arranca nada)
- Estructura de carpetas según arquitectura definida
- `.env.example` completo, `.gitignore` con `.env`
- Vault funcional: cifrar/descifrar credenciales con Fernet
- DB con SQLAlchemy async + primera migración Alembic
- `credential_scrubber()` en logging
- Docker + docker-compose funcionando
- CI básico (ruff + mypy + pytest)
### Sprint 1 — Autenticación de Usuarios en Telegram
- FSM: registro de usuario (username + password del sistema legacy)
- Almacenamiento cifrado de credenciales via vault
- Comando `/start` con flujo completo
- Middleware de autenticación (solo usuarios registrados pueden operar)
- Audit log de registros y accesos
### Sprint 2 — Gateway: Conexión al Sistema Legacy
- Session manager de Playwright
- Login al sistema documental (scraper)
- Primera operación real: búsqueda básica
- Manejo de errores del scraper traducidos a mensajes para el usuario
- Retry con backoff
### Sprint 3 — Operaciones Documentales
- Listado de resultados con paginación conversacional
- Descarga y envío de documentos al chat
- Caché de sesión activa (no re-login en cada operación)
### Sprint 4+ — Robustez y Fase 2
- Indicador de sesión expirada
- WhatsApp adapter
- Rate limiting por usuario
---
## Lo que esperás del director
- OK formal explícito antes de pasar a la siguiente etapa
- Validación de flujos conversacionales en Telegram real (no solo "tests verdes")
- Caza de anomalías en reportes con criterio adversarial
- Decisiones estratégicas: alcance, prioridades, qué se construye
- Corrección cuando te desviés del método
## Lo que el director espera de vos
- Prompts de etapa concretos, sin ambigüedad, con scope explícito
- Detección proactiva de decisiones riesgosas (especialmente en seguridad)
- Crítica directa cuando algo no cierra
- Memoria del método y las reglas — no repetir errores documentados
---
## Regla de marca
**"InQuality"** se escribe siempre completo con Q mayúscula. Si el bot lleva marca InQuality, coordinar antes de definir nombre, colores o logo. Hasta entonces, el bot se identifica con el sistema documental al que da acceso.
---
## Anti-patrones a detectar proactivamente
- **AP.SEGURIDAD-1:** Credenciales en cualquier output de string (logs, mensajes, excepciones)
- **AP.SEGURIDAD-2:** Sesión de Playwright compartida entre users
- **AP.SCRAPER-1:** Selectores hardcodeados fuera de `selectors.py`
- **AP.SCRAPER-2:** Operación de scraping sin timeout explícito
- **AP.ARCH-1:** Lógica de negocio en handlers de Telegram (va en `domain/`)
- **AP.ARCH-2:** Gateway importando de channels (violación de dependencias)
- **AP.METODO-1:** Etapa que arranca sin ETAPA_X_PROMPT.md aprobado
- **AP.METODO-2:** Reporte de Claude Code aceptado sin verificación visual/funcional
- **AP.METODO-3:** Adelanto silencioso — Claude Code implementa scope de etapas futuras sin consultar
---
*Método desarrollado en el proyecto InQ ROI (InQuality, 2025-2026). Transferido y adaptado al proyecto Bot Legal.*

View File

@@ -9,25 +9,32 @@
## Estado actual del proyecto
**Sprint:** 3 (próximo — planificación pendiente)
**Sprint:** 4 (planificado — BRIEF listo en `sprints/sprint-4/BRIEF.md`)
**Fase:** 1 — ROI y escenarios de automatización
**Deploy activo:** https://process-cost-platform.pages.dev
**Usuario objetivo actual:** equipo de InQuality — uso interno en talleres y pre-venta
**Usuario objetivo actual:** equipo de InQuality — uso interno en talleres y con clientes en curso
**Sprint 2 cerrado el 2026-05-20.** Entregó: modelo de recursos `utilizationMinutes + units` (migración Dexie v3), ActivityPanel "wow" con cálculo en tiempo real, reporte web con filas expandibles de desglose de recursos, edición inline de proceso/cliente, tabla de recursos en PDF (página condicional), 548 tests (7 nuevos). Ver case study: `simulador-web/methodology/CASE_STUDIES/inq-roi-sprint-2.md`.
**Sprint 3 cerrado el 2026-05-27.** Entregó: biblioteca de procesos con grupos/tags/búsqueda (Dexie v4), indicador stale de simulación, zoom BPMN fit-viewport, recursos TO-BE en escenario automatizado (motor + UI + reporte + PDF), branding InQ ROI en todas las páginas (AppHeader + favicon + logos), kit de socialización del equipo (guía de usuario, guía de pre-venta, presentación PPTX). 561 tests.
**Sprint 2 cerrado el 2026-05-20.** Entregó: modelo de recursos `utilizationMinutes + units` (migración Dexie v3), ActivityPanel "wow" con cálculo en tiempo real, reporte web con filas expandibles de desglose de recursos, edición inline de proceso/cliente, tabla de recursos en PDF (página condicional), 548 tests. Ver case study: `simulador-web/methodology/CASE_STUDIES/inq-roi-sprint-2.md`.
**Sprint 1.5 cerrado el 2026-05-18.** Entregó: identidad visual completa, PDFs de clase profesional (3 documentos), badge ⚡ en canvas BPMN, ROI con 5 KPIs, sistema de componentes establecido.
---
## Decisiones pendientes para Sprint 3 (no resueltas aún)
## Sprint 4 — dirección estratégica
- **Modelo B (AS-IS / TO-BE):** ¿abrimos la puerta a dos BPMNs o seguimos con Modelo A y mejoramos la experiencia?
- **Catálogo global de recursos:** ¿el valor de compartir recursos entre procesos justifica la complejidad en Sprint 3, o esperar a Sprint 4?
- **Indicador de simulación desactualizada:** alto impacto en talleres, bajo esfuerzo — candidato prioritario.
- **Posicionamiento:** primera conversación comprometida post-Sprint 2 (herramienta interna vs. venta externa).
Refactorización arquitectural completa. Sin features de producto nuevas.
Ver backlog completo en `simulador-web/BACKLOG.md`.
- **Auth:** Supabase + Google Workspace OAuth (`@inquality.com.py`). Abstracción `AuthService` para migración futura a Keycloak.
- **Base de datos:** reemplazo de IndexedDB/Dexie por PostgreSQL via Supabase. Datos existentes abandonados (eran solo pruebas).
- **Recursos globales:** catálogo compartido del equipo, sin `processId`. Rompe modelo actual — requiere mockup aprobado antes de Etapa 7.
- **Privacidad:** procesos con visibilidad `private | team`. Recursos, grupos y tags siempre compartidos. Simulaciones heredan del proceso.
- **Sin auditoría:** diferida a sprint dedicado.
- **Online-only:** aceptado. Offline en roadmap futuro.
- **Keycloak:** diferido. InQuality tiene VPS desa + prod. Keycloak será el auth server de la plataforma InQuality (multi-producto).
Ver `sprints/sprint-4/BRIEF.md` para scope completo.
---

View File

@@ -0,0 +1,212 @@
# Guía de Pre-venta — InQ ROI
> Guía para consultores de InQuality. Explica cómo usar InQ ROI en el contexto de una propuesta de automatización: qué mostrar, cuándo mostrarlo, qué preguntas hacer y cómo interpretar los resultados ante un cliente.
---
## ¿Para qué sirve esta guía?
Esta guía te ayuda a:
- Preparar la demo o el análisis de InQ ROI antes de una reunión con el cliente
- Saber qué preguntar para obtener los datos correctos
- Interpretar los resultados del simulador y traducirlos en argumentos de negocio
- Anticipar las objeciones más comunes y tener respuestas claras
No es un script rígido. Es un mapa de referencia para que cada consultor lo adapte a su estilo y al contexto del cliente.
---
## Cuándo usar InQ ROI en pre-venta
InQ ROI es más efectivo cuando:
- El cliente ya reconoce que tiene un proceso costoso, lento o propenso a errores — pero no sabe cuánto le cuesta exactamente.
- Estás compitiendo con otras propuestas y necesitás diferenciar con evidencia cuantitativa.
- El decisor financiero (CFO, gerente de operaciones) va a estar en la reunión.
- Tenés acceso a un BPMN del proceso, aunque sea una versión preliminar.
InQ ROI es menos relevante cuando:
- El cliente quiere automatizar algo pequeño y ya tiene la decisión tomada.
- El foco de la reunión es técnico (arquitectura, integración) y no de negocio.
- No tenés ningún dato de costos ni tiempos del proceso.
---
## Antes de la reunión — preparación
### 1. Conseguir el BPMN (o modelarlo vos)
Para usar InQ ROI necesitás un archivo `.bpmn` del proceso. Las fuentes posibles:
- El cliente lo tiene (si ya modelaron procesos antes)
- Tu equipo lo modela en base a entrevistas previas
- Lo modelás durante un taller de levantamiento con el cliente
> Si el cliente no tiene BPMN pero sí tiene documentación de proceso (Word, Visio, PowerPoint), ese material es suficiente para modelar un BPMN básico en Camunda o Bizagi antes de la reunión.
### 2. Recolectar los datos del proceso
Las preguntas que tenés que poder responder antes de cargar el simulador:
| Dato | Pregunta para el cliente | Fuente alternativa |
| --- | --- | --- |
| Frecuencia mensual | "¿Cuántas veces por mes ejecutan este proceso?" | Reportes operativos, sistema de gestión |
| Tiempo por actividad | "¿Cuánto tarda un operador en completar esta tarea?" | Observación directa, cronometraje |
| Roles involucrados | "¿Quiénes participan en el proceso y en qué actividades?" | Entrevistas, organigrama |
| Costo de roles | "¿Cuánto cuesta a la empresa (con cargas) cada perfil?" | RRHH, estimación por rango salarial |
| Costo de licencias / sistemas | "¿Qué sistemas usan en este proceso? ¿Tienen costo mensual?" | IT, contratos de licencia |
**Si el cliente no tiene datos exactos:** usá rangos y especificá que son estimaciones. El simulador es una herramienta de aproximación — el valor está en el orden de magnitud, no en los decimales.
### 3. Cargar el proceso en el simulador
Con el BPMN y los datos básicos, cargá el proceso en InQ ROI antes de la reunión. Tené listo:
- El reporte generado (PDF exportado)
- La pantalla del workspace abierta para mostrar el canvas en vivo si la reunión es presencial o por Zoom
---
## En la reunión — script de conversación
### Apertura: anclar en el problema antes de mostrar la herramienta
No abrir con la demo. Primero confirmá que el cliente reconoce el problema.
**Preguntas disparadoras:**
- *"¿Tienen una idea de cuánto les cuesta mensualmente ejecutar este proceso?"*
- *"¿Cuántas personas participan y cuánto tiempo les toma cada vez que lo ejecutan?"*
- *"¿Han tenido errores o retrasos en este proceso? ¿Qué impacto tienen?"*
El objetivo es que el cliente diga en voz alta que no sabe el costo exacto, o que lo sabe pero no está cuantificado formalmente. Eso crea el espacio para mostrar el simulador.
### Transición: presentar el enfoque cuantitativo
*"Antes de hablar de automatización, trabajamos con una herramienta interna para cuantificar el costo real del proceso. Así podemos comparar el AS-IS con el TO-BE y calcular el período de recupero de la inversión. ¿Les parece si revisamos los números que levantamos?"*
### Mostrar el AS-IS primero
**¿Por qué mostrar AS-IS antes que el ROI?**
El cliente necesita reconocerse en los números del proceso actual antes de valorar el ahorro proyectado. Si saltás directo al ROI, el cliente puede dudar de los números base y perder confianza en el análisis.
**Cómo presentar el AS-IS:**
1. Mostrar el canvas con el heatmap. Señalar las actividades más costosas (rojo/amarillo).
2. Presentar los KPIs: costo por ejecución, costo mensual, costo anual.
3. Preguntar: *"¿Estos números les parecen razonables? ¿Hay alguna actividad donde crean que el costo está sobreestimado o subestimado?"*
> Esta pregunta es clave. Si el cliente corrige un número, eso valida que tomaron en serio el análisis. Cada corrección es una oportunidad para afinar el modelo en vivo.
### Mostrar el TO-BE y el ROI
Una vez que el cliente validó el AS-IS:
1. Explicar cuáles actividades se identificaron como automatizables.
2. Mostrar los KPIs del TO-BE: ahorro mensual, ahorro anual.
3. Presentar el período de recupero: *"Con una inversión estimada de [X], recuperarían la inversión en [Y] meses."*
4. Cerrar con ROI: *"A 12 meses, el retorno sobre la inversión es de [Z]%."*
**Cómo presentar el período de recupero según el resultado:**
| Período de recupero | Argumento recomendado |
| --- | --- |
| Menos de 6 meses | "La inversión se recupera antes de que terminen de implementar el proceso." |
| 6 a 12 meses | "En el primer año ya están ganando. El segundo y tercer año son ganancia neta." |
| 12 a 24 meses | "El período de recupero es razonable para una automatización de esta escala. El beneficio real es la liberación de capacidad del equipo." |
| Más de 24 meses | Complementar con beneficios cualitativos: calidad, trazabilidad, escalabilidad, reducción de riesgo operacional. |
### Cierre: el paso siguiente
No dejar la reunión sin un próximo paso claro.
Opciones según el momento de la venta:
- *"¿Podemos coordinar una sesión de validación de datos con el equipo de operaciones para afinar el modelo?"* → Entrar más profundo en el relevamiento.
- *"¿Les compartiríamos este análisis por escrito para que lo lleven al comité?"* → Enviar el PDF exportado.
- *"¿Agendamos la presentación de la propuesta técnica para la semana que viene?"* → Avanzar a propuesta formal.
---
## Cómo interpretar los resultados ante el cliente
### Si el costo AS-IS parece muy alto
Primero revisá los datos: ¿la frecuencia está correcta? ¿Los tiempos son reales o estimados al alza? ¿Se incluyeron roles que no deberían estar?
Si el modelo es correcto: *"Estos números reflejan el costo real del proceso incluyendo todos los roles involucrados. Muchas veces el costo no es visible porque está distribuido en el tiempo de múltiples personas."*
### Si el período de recupero es largo
Complementar con el análisis a 3 años: *"A 36 meses, el ROI es del [X]%. Además, la automatización elimina el riesgo de errores manuales — si un error tiene consecuencias regulatorias o de reputación, el costo evitado también cuenta."*
### Si el cliente cuestiona los datos
*"Estos son los datos que relevamos en base a la información que nos compartieron. Si tienen datos más precisos, los incorporamos al modelo ahora mismo — eso es parte del valor del análisis."*
Esto transforma la objeción en una oportunidad de colaboración.
### Si el cliente pregunta por qué automatizar y no contratar más personal
*"Contratar más personal escala el costo linealmente con el volumen. La automatización tiene un costo fijo de implementación y luego el costo por ejecución cae drásticamente. Miremos el gráfico de costo mensual proyectado a 2 años..."*
---
## Preguntas de calificación del cliente
Antes de invertir tiempo en preparar un análisis completo, estas preguntas ayudan a evaluar si el cliente es un buen candidato:
| Pregunta | Respuesta positiva | Señal de alerta |
| --- | --- | --- |
| "¿Cuántas veces por mes ejecutan este proceso?" | Responde con número concreto | "No sé, varía mucho" (sin datos = análisis difícil) |
| "¿Tienen documentado el proceso?" | Sí, tienen BPMN o manual | "Está en la cabeza del equipo" (requiere levantamiento previo) |
| "¿Quién toma la decisión de automatizar?" | Persona presente en la reunión | "Hay que consultarle a otro" (proceso de venta largo) |
| "¿Tienen presupuesto para esta iniciativa?" | Sí, o están evaluando | "No hay presupuesto definido" (priorizar otras oportunidades) |
| "¿Cuál es el plazo en que esperan resultados?" | 6 a 18 meses | "Queremos resultados en 2 meses" (expectativa desalineada) |
---
## Errores comunes en pre-venta con el simulador
**Mostrar el simulador antes de entender el problema del cliente.**
El simulador es un argumento, no una demo de producto. Primero el problema, después la evidencia.
**Presentar números que el cliente no reconoce.**
Si el costo mensual que muestra InQ ROI es muy diferente de lo que el cliente intuía, hay que detenerse ahí y discutir la diferencia. Seguir adelante sin resolver esa tensión debilita la presentación.
**No tener el PDF exportado antes de la reunión.**
El PDF es el material de follow-up. Si la reunión termina sin dejarle algo tangible al cliente, el análisis se diluye.
**Prometer exactitud que el modelo no tiene.**
InQ ROI es un modelo de aproximación, no una auditoría contable. Siempre comunicar las asunciones: *"Estos números están basados en los datos que relevamos — si hay diferencias en los costos reales, el modelo se actualiza."*
**Usar el simulador como reemplazo del levantamiento de procesos.**
El simulador es más efectivo después de un levantamiento serio. Si los datos de entrada son malos, los números de salida también lo son. Aplicar el principio: garbage in, garbage out.
---
## Materiales para llevar a la reunión
- **PDF exportado del análisis AS-IS y TO-BE** — siempre, incluso si vas a mostrar la pantalla en vivo
- **Documento con las asunciones del modelo** — lista de los costos/hora usados, la frecuencia y las probabilidades de gateway asumidas. Transparencia construye confianza.
- **Propuesta de próximos pasos** — una hoja o slide con las opciones concretas para avanzar
---
## Plantilla de notas post-reunión
Después de cada reunión donde usaste InQ ROI, registrá:
**Cliente:** [nombre]
**Proceso analizado:** [nombre del proceso]
**Datos validados por el cliente:** [sí / parcialmente / no]
**Ajustes acordados al modelo:** [descripción]
**Objeciones planteadas:** [lista]
**Próximo paso acordado:** [acción + fecha]
**Estado del proceso de venta:** [caliente / tibio / frío]
---
*InQ ROI — Powered by InQuality*

View File

@@ -0,0 +1,285 @@
# Guía de Usuario — InQ ROI
> Guía completa para el equipo de InQuality. Describe cómo usar la herramienta paso a paso, qué significa cada campo, qué errores evitar y cómo aprovecharla al máximo en talleres y pre-venta.
---
## ¿Qué es InQ ROI?
InQ ROI es la plataforma web de InQuality para **cuantificar el costo operativo de un proceso** y calcular el **retorno de inversión (ROI) de automatizarlo**.
En lugar de estimar a ojo, InQ ROI toma un archivo BPMN del proceso real y produce números: cuánto cuesta hoy, cuánto costaría después de automatizar, y cuánto tiempo tardaría en recuperar la inversión.
**Para quién es:** consultores de InQuality que analizan procesos con clientes, ya sea en talleres de levantamiento o en propuestas de automatización.
**Lo que NO es:** no es un simulador de tiempo, no reemplaza el BPMN, no genera proyecciones financieras complejas (sin VPN, sin Monte Carlo). Es intencional: los números son simples y explicables.
---
## Acceso
La herramienta está disponible en:
**https://process-cost-platform.pages.dev**
No requiere instalación ni login. Corre en el navegador. Los datos se guardan localmente en el dispositivo (no en la nube).
> **Aclaración técnica:** los datos persisten en el IndexedDB del navegador. Esto significa que si usás un navegador diferente, otro dispositivo, o modo incógnito, no vas a ver los procesos que cargaste antes. Para compartir datos entre equipos, usá la función de exportar/importar BPMN (disponible en la pantalla de importación).
---
## Flujo general de trabajo
El flujo tiene cinco pasos, siempre en este orden:
1. Importar el archivo BPMN del proceso
2. Configurar los datos del proceso (nombre, cliente, frecuencia)
3. Definir los recursos (personas, sistemas, herramientas y sus costos)
4. Asignar recursos a cada actividad del proceso
5. Ejecutar la simulación y analizar el reporte
---
## Paso 1 — Importar el proceso BPMN
### Cómo hacerlo
En la pantalla de inicio (biblioteca), hacé clic en **"Importar proceso"**. Seleccioná el archivo `.bpmn` del proceso que querés analizar.
El archivo debe ser un BPMN 2.0 válido. Si el proceso fue modelado en Camunda, Bizagi o herramientas similares, el archivo exportado ya tiene el formato correcto.
### Qué pasa después de importar
La herramienta lee el BPMN y muestra el diagrama en el canvas. Cada actividad (tarea) del proceso aparece como un bloque que podés seleccionar para configurar sus parámetros.
> **Aclaración técnica:** InQ ROI solo lee el BPMN — no lo modifica. El archivo original queda intacto. Si el cliente quiere ver el diagrama modificado, ese trabajo sigue siendo en la herramienta de modelado (Camunda, Bizagi, etc.).
### Qué errores evitar
- **No importar BPMNs sin actividades:** si el archivo tiene solo eventos y gateways, la simulación no va a poder calcular costos.
- **Los nombres de las actividades importan:** InQ ROI muestra los nombres exactos del BPMN en el reporte. Si el BPMN tiene nombres vagos como "Tarea 1", el reporte va a ser igual de vago. Asegurate de que el cliente tenga el BPMN con nombres descriptivos antes del taller.
---
## Paso 2 — Configurar el proceso
Una vez importado el BPMN, completá los datos generales del proceso en el panel lateral:
| Campo | Qué es | Impacto |
| --- | --- | --- |
| Nombre del proceso | Título que aparece en el reporte y la biblioteca | Solo identificación, no afecta cálculos |
| Cliente | Nombre de la empresa del cliente | Solo identificación, aparece en el reporte PDF |
| Moneda | Símbolo monetario (PYG, USD, BRL, etc.) | Aparece en todos los campos de costo del reporte |
| Frecuencia de ejecución | Cuántas veces por mes se ejecuta el proceso completo | **Afecta directamente el costo mensual total** |
### Sobre la frecuencia de ejecución
Este es el campo que más confunde. Algunos ejemplos para calibrar:
- "Liquidación de sueldos" → ocurre 1 vez por mes → frecuencia = 1
- "Aprobación de facturas" → ocurre 200 veces por mes → frecuencia = 200
- "Cierre contable" → ocurre 12 veces por año → convertir a mensual = 1
> **Aclaración técnica:** la frecuencia se usa como multiplicador del costo por ejecución. El motor calcula el costo de una ejecución completa y lo multiplica por la frecuencia para obtener el costo mensual. Si la frecuencia del cliente varía mucho (ej. entre 150 y 300 por mes), usá el promedio o hacé dos escenarios.
---
## Paso 3 — Definir los recursos
Los recursos son las "partes" que participan en el proceso: personas (roles), sistemas, herramientas o bots. Cada recurso tiene un **costo por hora** asociado.
### Cómo agregar un recurso
En el panel lateral, accedé a la sección **"Recursos"** y hacé clic en **"Agregar recurso"**. Completá:
| Campo | Qué es | Ejemplo |
| --- | --- | --- |
| Nombre | Cómo se llama el recurso en el contexto del proceso | "Analista de crédito", "Sistema SAP", "Bot de OCR" |
| Tipo | Categoría del recurso | Humano, Sistema, Herramienta |
| Costo por hora | Cuánto cuesta por hora de uso | 15.000 PYG/h, 25 USD/h |
### Cómo calcular el costo por hora
Para personas, el costo por hora se calcula dividiendo el salario mensual (costo total para la empresa, incluyendo cargas sociales) por las horas laborables del mes.
**Fórmula práctica:**
```
Costo/hora = Salario mensual (con cargas) ÷ 160 horas
```
Ejemplo: analista con salario total de 2.400.000 PYG/mes → 2.400.000 ÷ 160 = **15.000 PYG/hora**
Para sistemas o licencias, el costo por hora se estima dividiendo el costo mensual de la licencia por las horas en que el sistema está disponible para ese proceso.
> **Aclaración técnica:** el costo por hora es el único parámetro económico del recurso. La herramienta no modela costos fijos de infraestructura ni costos variables por volumen. Si el cliente tiene modelos de costo más complejos, se puede aproximar ajustando el costo/hora con un promedio ponderado.
### Qué errores evitar
- **No usar salario de bolsillo:** usar siempre el costo total para la empresa (incluye aportes patronales, aguinaldo, beneficios). El salario de bolsillo subestima el costo real en un 30-50%.
- **No crear un recurso "genérico" para todo:** si en el proceso participan un analista junior y un gerente, crear dos recursos separados con sus costos reales. Un solo recurso promediado distorsiona los cálculos de las actividades críticas.
---
## Paso 4 — Configurar las actividades
Esta es la parte central de la herramienta. Hacé clic en cada actividad del canvas para abrir su panel de configuración.
### Pestaña "AS-IS" — El proceso actual
Configurá cómo se ejecuta hoy la actividad:
| Campo | Qué es | Impacto en el cálculo |
| --- | --- | --- |
| Tiempo (minutos) | Cuántos minutos toma ejecutar esta actividad una vez | Base del costo de tiempo |
| Costo fijo | Costo adicional fijo por ejecución (ej. costo de formulario, impresión) | Se suma al costo de recursos |
| Asignación de recursos | Qué recursos participan, cuántos minutos usan y cuántas unidades | Determina el costo de recursos de la actividad |
| Probabilidad de ejecución | Qué porcentaje de las veces que llega el flujo a esta actividad, se ejecuta realmente | Multiplica el costo total de la actividad |
### Sobre la asignación de recursos
Cada recurso asignado a una actividad tiene dos parámetros:
- **Minutos de utilización:** cuántos minutos del tiempo total de la actividad usa ese recurso activamente. Puede ser menor al tiempo total de la actividad (ej. el analista trabaja 10 minutos, pero el proceso espera 20 minutos más por una firma).
- **Unidades:** cuántas instancias del recurso participan simultáneamente. Ej. si dos operadores hacen la misma tarea en paralelo, unidades = 2.
**Fórmula de costo de un recurso en una actividad:**
```
Costo = (costo/hora del recurso) × (minutos de utilización ÷ 60) × unidades
```
> **Aclaración técnica:** los minutos de utilización son independientes del tiempo total de la actividad. Una actividad puede durar 60 minutos pero el analista solo trabajar 15 (el resto es tiempo de espera del sistema). Modelar esto correctamente es importante para no sobreestimar el costo del recurso humano.
### Sobre la probabilidad de ejecución
La probabilidad de ejecución modela los gateways (decisiones) del proceso. Si una actividad está en la rama "rechazado" de un gateway donde el 80% de los casos van a "aprobado", la probabilidad de ejecución de esa actividad es 20%.
**Cuándo modificarla:** solo cuando la actividad tiene paths alternativos en el BPMN. Para la mayoría de las actividades en un flujo lineal, la probabilidad es 100%.
> **Aclaración técnica:** InQ ROI no lee automáticamente las probabilidades de los gateways del BPMN — hay que cargarlas manualmente. Esto es intencional: los BPMNs raramente tienen esa data, y si la tienen no es confiable sin validación del cliente.
### Pestaña "TO-BE" — El proceso automatizado
Esta pestaña aparece en las actividades marcadas como **automatizables**. Configurá cómo quedaría la actividad después de automatizar:
| Campo | Qué es |
| --- | --- |
| Tiempo automatizado (minutos) | Cuánto tardará la actividad cuando esté automatizada |
| Costo fijo automatizado | Costo fijo por ejecución en el escenario automatizado (ej. costo de API, licencia de bot por llamada) |
| Recursos TO-BE | Recursos que participan en el escenario automatizado (ej. bot, sistema RPA) |
### Qué errores evitar al configurar actividades
- **No confundir tiempo de actividad con tiempo de recurso:** una actividad puede durar 2 horas pero el recurso humano actúa solo 20 minutos. Si ponés 120 minutos de utilización, el costo va a estar multiplicado por 6.
- **No olvidar las actividades de control/supervisión en el TO-BE:** muchas veces la automatización no elimina el rol humano, sino que lo cambia a supervisión. Acordarse de modelar ese costo residual en el TO-BE.
- **No dejar probabilidades en 100% cuando hay gateways:** si el reporte muestra un costo muy alto en una actividad de "excepción" que debería ocurrir poco, revisar la probabilidad.
---
## Paso 5 — Ejecutar la simulación y analizar el reporte
### Cómo ejecutar
En el workspace, hacé clic en el botón **"Simular"** (o similar según la versión). La simulación corre instantáneamente — no hay tiempo de espera.
### El reporte
El reporte tiene dos secciones principales:
**KPIs de costo (AS-IS):**
| KPI | Qué mide |
| --- | --- |
| Costo por ejecución | Costo total de ejecutar el proceso una vez |
| Costo mensual | Costo por ejecución × frecuencia mensual |
| Costo anual | Costo mensual × 12 |
**KPIs de ROI (si hay actividades automatizables configuradas):**
| KPI | Qué mide |
| --- | --- |
| Ahorro mensual | Diferencia de costo mensual entre AS-IS y TO-BE |
| Ahorro anual | Ahorro mensual × 12 |
| Período de recupero | Inversión ÷ ahorro mensual (en meses) |
| ROI a 12 meses | (Ahorro anual - Inversión) ÷ Inversión × 100% |
| ROI a 36 meses | Igual pero con ahorro de 3 años |
> **Aclaración técnica:** el ROI es nominal simple — no descuenta el valor del dinero en el tiempo. Para propuestas donde el cliente exige VPN o TIR, los números de InQ ROI son el punto de partida, no el análisis financiero completo.
### Las filas expandibles de actividades
En la tabla de actividades del reporte, cada fila se puede expandir para ver el **desglose de costos por recurso**. Esto es muy útil en talleres: permite identificar exactamente qué recurso está consumiendo más tiempo o dinero en cada actividad.
En actividades automatizables con recursos TO-BE configurados, el desglose muestra las dos columnas: AS-IS y TO-BE.
### El heatmap de costos
El canvas BPMN del reporte muestra un heatmap de colores sobre las actividades: verde (bajo costo), amarillo (costo medio), rojo (alto costo). Esto permite identificar visualmente cuáles son los cuellos de botella económicos del proceso.
### Exportar el PDF
El botón de exportar genera un PDF profesional con el logo de InQuality, los KPIs, la tabla de actividades y el desglose de recursos. El PDF tiene dos versiones: "Actual" (AS-IS) y "Automatizado" (TO-BE).
> **Validación antes de compartir con el cliente:** revisar que el nombre del proceso y el nombre del cliente están correctos antes de exportar. Esos datos aparecen en el encabezado del PDF.
---
## Cómo sacar mayor provecho
### En talleres de levantamiento de procesos
1. **No modelar en tiempo real delante del cliente.** Levantar el proceso primero (post-its, conversación, BPMN borrador). Cargar los datos después del taller.
2. **Usarlo como conversación, no como resultado final.** Mostrar el reporte y preguntar: "¿Este costo de 4 horas por ejecución te parece razonable?" El cliente va a corregir los datos, y eso es valioso.
3. **El campo de frecuencia es un disparador de conversación.** "¿Cuántas veces por mes procesan facturas?" suele abrir una conversación sobre volúmenes que el cliente no había pensado.
### En propuestas de automatización
1. **El período de recupero es el número que más importa en la reunión ejecutiva.** Si es menor a 12 meses, la propuesta se defiende sola. Si es mayor a 24, hay que justificar con otros beneficios (calidad, trazabilidad, riesgo).
2. **Mostrar primero el AS-IS, no el ROI.** El cliente necesita reconocerse en el costo actual antes de valorar el ahorro proyectado.
3. **El TO-BE debe ser conservador.** Es mejor mostrar un período de recupero de 14 meses que resultó en 10 que prometer 8 meses que resultó en 14. Usar estimaciones de tiempo y costo de automatización con margen.
### Buenas prácticas generales
- Guardar el proceso en la biblioteca antes de exportar el PDF. Los datos persisten en el navegador pero no en la nube.
- Si vas a presentar desde otro dispositivo o en la oficina del cliente, exportar el PDF antes y llevarlo. No depender de conectividad para la presentación.
- Usar nombres de proceso con el formato "Proceso — Cliente — Fecha" en la biblioteca para poder encontrarlos después.
---
## Preguntas frecuentes
**¿Puedo cargar varios procesos del mismo cliente?**
Sí. Cada proceso importado es independiente en la biblioteca. Podés tener 10 procesos de un mismo cliente.
**¿El archivo BPMN original se modifica?**
No. InQ ROI solo lee el BPMN para extraer la estructura. El archivo original no se toca.
**¿Puedo exportar los datos para compartir con otro miembro del equipo?**
Por ahora los datos viven en el navegador del dispositivo donde se cargaron. Para compartir, el más práctico es exportar el PDF y compartirlo. La funcionalidad de exportar/importar datos de proceso completo está planificada para versiones futuras.
**¿Qué pasa si el proceso tiene loops (actividades que se repiten)?**
Los loops no se modelan automáticamente desde el BPMN. Se aproximan ajustando la probabilidad de ejecución o el número de ejecuciones en la frecuencia mensual del proceso. Es una limitación conocida del modelo actual.
**¿Puedo calcular el ROI sin tener todos los costos exactos?**
Sí. Una práctica útil es hacer dos versiones: una con costos "estimados razonables" y otra con costos "conservadores". El rango resultante es más honesto que un único número preciso.
---
## Glosario rápido
| Término | Definición |
| --- | --- |
| AS-IS | El proceso tal como existe hoy, antes de cualquier mejora |
| TO-BE | El proceso como quedaría después de automatizar |
| Frecuencia | Cuántas veces por mes se ejecuta el proceso completo |
| Probabilidad de ejecución | Porcentaje de veces que una actividad se ejecuta cuando el flujo la alcanza |
| Minutos de utilización | Cuántos minutos usa activamente un recurso dentro de una actividad |
| Unidades | Cuántas instancias simultáneas de un recurso participan en una actividad |
| Costo fijo | Costo por ejecución independiente del tiempo (ej. costo de formulario, API call) |
| Período de recupero | Tiempo en meses para que el ahorro cubra la inversión inicial |
| ROI | Retorno sobre la inversión, expresado en porcentaje |
---
*InQ ROI — Powered by InQuality*

6
docs/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.env*
.git
tests
*.md

25
nginx.conf Normal file
View File

@@ -0,0 +1,25 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA routing — todas las rutas van a index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache agresivo para assets con hash en el nombre
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# No cachear index.html
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1000;
}

7
package-lock.json generated
View File

@@ -27,7 +27,6 @@
"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",
@@ -5220,12 +5219,6 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/dexie": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz",
"integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==",
"license": "Apache-2.0"
},
"node_modules/diagram-js": {
"version": "15.14.0",
"resolved": "https://registry.npmjs.org/diagram-js/-/diagram-js-15.14.0.tgz",

View File

@@ -32,7 +32,6 @@
"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",

View File

@@ -0,0 +1,314 @@
# Sprint 4 — Etapa 2: Schema PostgreSQL + migración de repositorios (procesos + grupos + settings)
## Contexto
InQ ROI es una herramienta web de análisis de procesos BPMN y ROI de automatización.
Stack: React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind.
Etapa 1 (completada): auth con Google, rutas protegidas, tabla `users` en Supabase, `AuthService` abstraction.
Esta etapa migra la persistencia de **procesos, grupos de biblioteca y settings** de IndexedDB/Dexie a Supabase PostgreSQL.
Las actividades, gateways, recursos y simulaciones siguen en Dexie hasta Etapas 3-4.
---
## Prerequisitos — verificar antes de empezar
- `.env.local` tiene `VITE_SUPABASE_URL` y `VITE_SUPABASE_ANON_KEY`
- El script `supabase/migrations/001_create_users.sql` ya fue ejecutado en Supabase
- `src/auth/AuthContext.tsx` y `useAuth()` hook existen y funcionan
---
## Alcance estricto — qué entra en esta etapa
1. **Scripts SQL** para `processes`, `process_groups`, `app_settings` en `supabase/migrations/`
2. **Nuevos repositorios Supabase** para procesos, grupos y settings (reemplazando las implementaciones Dexie de esas entidades)
3. **Actualizar los stores** (`useProcessStore`, `useLibraryStore`) para pasar `userId` a los repos en operaciones de escritura
4. **Limpieza de estado híbrido**: el `delete` de proceso sigue limpiando Dexie (activities, gateways, resources, simulations) hasta que se migren en Etapa 3-4
## Qué NO entra en esta etapa
- Migración de actividades, gateways, resources, simulations → Etapa 3
- Tabla `user_groups` y `group_memberships` (acceso a usuarios) → Etapa 5
- Tabla `process_access` → Etapa 5
- UI de visibilidad privado/compartido → Etapa 5
- Avatar/nombre en AppHeader → Etapa 6
- Eliminar Dexie del proyecto → Etapa 4 (cuando todo esté migrado)
- Cambiar el tipo TypeScript `Process.clientName` — NO modificar `src/domain/types.ts`
---
## Distinción importante: dos tipos de grupos
**`process_groups`** (esta etapa): grupos para organizar la biblioteca de procesos — "Cliente X", "Sin clasificar". Corresponde a `ProcessGroup` en `src/domain/types.ts`. Son compartidos por todo el equipo, sin control de acceso propio.
**`user_groups`** (Etapa 5): grupos de usuarios para control de acceso — "Equipo de consultoría", "Cliente Acme". Concepto nuevo, no existe en Dexie. No crear en esta etapa.
---
## Scripts SQL — crear en `supabase/migrations/`
### `002_create_process_groups.sql`
```sql
CREATE TABLE IF NOT EXISTS public.process_groups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_by uuid REFERENCES public.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.process_groups ENABLE ROW LEVEL SECURITY;
-- Todos los autenticados pueden leer y escribir grupos (son recursos compartidos del equipo)
CREATE POLICY "all_process_groups" ON public.process_groups
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);
```
### `003_create_processes.sql`
```sql
CREATE TABLE IF NOT EXISTS public.processes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
client text NOT NULL DEFAULT '',
bpmn_xml text NOT NULL DEFAULT '',
currency text NOT NULL DEFAULT 'USD',
overhead_percentage numeric NOT NULL DEFAULT 0,
annual_frequency numeric NOT NULL DEFAULT 1000,
analysis_horizon_years numeric NOT NULL DEFAULT 3,
automation_investment numeric NOT NULL DEFAULT 0,
group_id uuid REFERENCES public.process_groups(id) ON DELETE SET NULL,
tags text[] NOT NULL DEFAULT '{}',
owner_id uuid NOT NULL REFERENCES public.users(id),
created_by uuid NOT NULL REFERENCES public.users(id),
updated_by uuid NOT NULL REFERENCES public.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.processes ENABLE ROW LEVEL SECURITY;
-- En Sprint 4 todos los usuarios son platform_admin: ven todo.
-- Las políticas granulares (process_access) se agregan en Etapa 5.
-- Por ahora: cualquier usuario autenticado puede leer y escribir todos los procesos.
CREATE POLICY "authenticated_all_processes" ON public.processes
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);
```
### `004_create_app_settings.sql`
```sql
-- Settings globales del equipo (singleton compartido)
CREATE TABLE IF NOT EXISTS public.app_settings (
id text PRIMARY KEY DEFAULT 'singleton',
group_label text NOT NULL DEFAULT 'Cliente',
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.app_settings ENABLE ROW LEVEL SECURITY;
-- Todos los autenticados pueden leer y actualizar settings
CREATE POLICY "all_app_settings" ON public.app_settings
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);
-- Insertar el singleton con defaults
INSERT INTO public.app_settings (id, group_label)
VALUES ('singleton', 'Cliente')
ON CONFLICT (id) DO NOTHING;
```
Estos scripts se ejecutan **manualmente** en Supabase Dashboard → SQL Editor, en orden numérico.
---
## Mapeo de tipos TypeScript ↔ PostgreSQL
Los repositorios Supabase deben mapear entre los tipos del dominio (camelCase, timestamps como `number` ms Unix) y las columnas de PostgreSQL (snake_case, `timestamptz`).
**NO modificar `src/domain/types.ts`**. El mapeo es responsabilidad del repo.
### Process → processes
| TypeScript (`Process`) | PostgreSQL (`processes`) | Notas |
|---|---|---|
| `id` | `id` | igual |
| `name` | `name` | igual |
| `clientName` | `client` | rename solo en DB |
| `bpmnXml` | `bpmn_xml` | snake_case |
| `currency` | `currency` | igual |
| `overheadPercentage` | `overhead_percentage` | snake_case |
| `annualFrequency` | `annual_frequency` | snake_case |
| `analysisHorizonYears` | `analysis_horizon_years` | snake_case |
| `automationInvestment` | `automation_investment` | snake_case |
| `groupId` | `group_id` | snake_case |
| `tags` | `tags` | igual (array) |
| `createdAt` (number ms) | `created_at` (timestamptz) | `new Date(createdAt).toISOString()` |
| `updatedAt` (number ms) | `updated_at` (timestamptz) | igual conversión |
| — | `owner_id` | nuevo campo, inyectado por el store |
| — | `created_by` | nuevo campo, inyectado en primer save |
| — | `updated_by` | nuevo campo, inyectado en cada save |
Para `from_db` (PostgreSQL → TypeScript):
- `createdAt: new Date(row.created_at).getTime()`
- `updatedAt: new Date(row.updated_at).getTime()`
- `clientName: row.client`
### ProcessGroup → process_groups
| TypeScript (`ProcessGroup`) | PostgreSQL (`process_groups`) |
|---|---|
| `id` | `id` |
| `name` | `name` |
| `createdAt` (number ms) | `created_at` (timestamptz) |
| `updatedAt` (number ms) | `updated_at` (timestamptz) |
### AppSettings → app_settings
| TypeScript (`AppSettings`) | PostgreSQL (`app_settings`) |
|---|---|
| `id` ('singleton') | `id` |
| `groupLabel` | `group_label` |
---
## Nuevos repositorios — estructura de archivos
Crear `src/persistence/supabase/` con:
```
src/persistence/supabase/
├── process-repo.ts
├── group-repo.ts
└── settings-repo.ts
```
Cada archivo importa `supabase` de `src/lib/supabase.ts` y expone una interfaz idéntica al repo Dexie correspondiente, con la firma extendida donde sea necesario para `userId`.
### `src/persistence/supabase/process-repo.ts`
```typescript
// Contrato (misma interface que el repo Dexie, más userId donde aplica)
export const supabaseProcessRepo = {
async save(process: Process, userId: string): Promise<void>
async getById(id: string): Promise<Process | undefined>
async getAll(): Promise<Process[]>
async delete(id: string): Promise<void> // ver nota sobre estado híbrido
}
```
**Nota sobre `delete` en estado híbrido:**
La tabla `processes` está en Supabase. Las tablas `activities`, `gateways`, `resources`, `simulations` todavía están en Dexie. El `delete` debe:
1. Eliminar el proceso de Supabase
2. Importar `db` de `src/persistence/db.ts` y limpiar las tablas Dexie relacionadas
Es feo pero temporal. Dejar un comentario `// TODO Sprint 4 Etapa 3-4: eliminar limpieza Dexie cuando actividades migren a Supabase`.
### `src/persistence/supabase/group-repo.ts`
```typescript
export const supabaseGroupRepo = {
async save(group: ProcessGroup, userId: string): Promise<void>
async getAll(): Promise<ProcessGroup[]>
async getById(id: string): Promise<ProcessGroup | undefined>
async delete(id: string): Promise<void> // debe hacer SET NULL en processes.group_id (la FK ya tiene ON DELETE SET NULL)
}
```
### `src/persistence/supabase/settings-repo.ts`
```typescript
export const supabaseSettingsRepo = {
async get(): Promise<AppSettings>
async update(updates: Partial<Omit<AppSettings, 'id'>>): Promise<void>
}
```
---
## Actualización de stores
Los stores Zustand llaman a los repos. Con el cambio a Supabase, los repos de escritura necesitan el `userId`. Los stores obtienen el usuario actual del `AuthContext`.
### Patrón recomendado en los stores:
```typescript
// En el store, acceder al userId desde el contexto de auth
// Los stores son módulos, no React components — no pueden usar hooks directamente.
// Opciones:
// A) Pasar userId como parámetro a las acciones del store que lo necesitan
// B) Leer el usuario del AuthContext en el componente que llama al store y pasarlo
// C) Usar un getter del store de auth
// Opción recomendada: exponer un authStore simple con el userId,
// o pasar userId como parámetro a las acciones de escritura del store.
```
Claude Code debe elegir el patrón más limpio dado el código existente de los stores. La regla es: **ningún componente llama al repo directamente** — todo pasa por el store.
---
## Criterios de validación — esta etapa está completa cuando
- [ ] `npm run build` sin errores TypeScript
- [ ] `npm run test` — 561 tests siguen verdes (los tests de dominio no tocan los repos)
- [ ] En la biblioteca se pueden crear y listar procesos (datos vienen de Supabase, no de Dexie)
- [ ] En Supabase Dashboard → Table Editor → `processes`: aparecen los procesos creados
- [ ] En Supabase Dashboard → Table Editor → `processes`: las columnas `owner_id`, `created_by`, `updated_by` tienen el UUID del usuario que los creó
- [ ] Crear un grupo de biblioteca → aparece en Supabase tabla `process_groups`
- [ ] Setting `groupLabel` → aparece/actualiza en Supabase tabla `app_settings`
- [ ] Eliminar un proceso → desaparece de Supabase **y** sus actividades/gateways/resources/simulations desaparecen de Dexie
- [ ] No hay imports directos de la tabla Dexie `processes`/`groups`/`settings` fuera de `db.ts` (los repos Supabase no usan esas tablas)
- [ ] Validación visual del director: biblioteca cargando procesos desde Supabase
---
## Reporte esperado al completar
1. Lista de archivos creados/modificados
2. Resultado de `npm run test` (número de tests, pass/fail)
3. Resultado de `npm run build`
4. Captura de pantalla de la biblioteca con al menos un proceso cargado
5. Screenshot de Supabase Table Editor mostrando `processes` con `owner_id` populado
6. Descripción de cómo se resolvió el patrón `userId` en los stores
7. Cualquier decisión de iniciativa fuera del scope (clasificar nivel 1/2/3)
---
## Referencia de archivos existentes
| Archivo | Relevancia |
|---|---|
| `src/persistence/db.ts` | Schema Dexie actual — referencia para entender el modelo |
| `src/persistence/repositories.ts` | Repos Dexie actuales — base para los nuevos repos Supabase |
| `src/domain/types.ts` | Tipos TypeScript — NO modificar |
| `src/store/` | Stores Zustand a actualizar |
| `src/lib/supabase.ts` | Cliente Supabase singleton |
| `src/auth/AuthContext.tsx` | Fuente del userId actual |
| `supabase/migrations/001_create_users.sql` | Referencia de estilo para los nuevos scripts |
---
## Notas arquitectónicas
### Estado híbrido es intencional
Al terminar esta etapa, la app tiene:
- Procesos, grupos, settings → Supabase PostgreSQL
- Actividades, gateways, recursos, simulaciones → Dexie (hasta Etapa 3-4)
Esto es correcto. No anticipar la migración de las tablas restantes.
### Los tests de dominio no se rompen
Los tests en `src/domain/` son funciones puras sin persistencia. No dependen de Dexie ni de Supabase. Deben seguir verdes sin cambios.
Los tests de integración que usan los repos directamente (si los hay) pueden necesitar mocks de Supabase — evaluar caso por caso.
### Supabase genera `updated_at` automáticamente
Para que `updated_at` se actualice automáticamente en cada UPDATE, se puede agregar un trigger PostgreSQL, o simplemente incluir `updated_at: new Date().toISOString()` en cada `UPDATE` desde el repo. Usar el approach del repo (más simple, sin trigger en esta etapa).

View File

@@ -0,0 +1,392 @@
# Sprint 4 — Etapa 3: Migración de actividades, gateways y recursos globales
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind.
Etapas 1 y 2 completadas: auth con Google, rutas protegidas, procesos/grupos/settings en Supabase.
Esta etapa migra actividades, gateways y recursos de Dexie a Supabase.
El cambio más significativo: **recursos pasan de ser por-proceso a un catálogo global del equipo**.
Al terminar esta etapa, Dexie solo retiene **simulaciones** (Etapa 4).
---
## Prerequisitos — verificar antes de empezar
- Scripts 001-004 ejecutados en Supabase (de Etapas 1 y 2)
- `src/auth/AuthContext.tsx` con `useAuth()` funcionando
- `src/persistence/supabase/` con process-repo, group-repo, settings-repo
---
## Alcance estricto — qué entra en esta etapa
1. **Scripts SQL** para `resources`, `activities`, `activity_resource_assignments`, `gateways`
2. **Nuevos repos Supabase** para las cuatro entidades
3. **Actualizar `process-store.ts`** para usar repos Supabase
4. **Actualizar `useReportData.ts`** para cargar recursos desde Supabase
5. **Actualizar `ResourcesPanel.tsx`** mínimamente: crear recursos sin `processId`
6. **Actualizar `process-repo.ts` delete**: eliminar limpieza Dexie de activities/gateways/resources (pasan a CASCADE en Supabase); conservar limpieza Dexie solo para simulaciones
7. **Modificar tipo `Resource`** en `src/domain/types.ts`: hacer `processId` opcional (`processId?: UUID`)
## Qué NO entra en esta etapa
- Migración de simulaciones → Etapa 4
- Eliminar Dexie del proyecto → Etapa 4
- UX de catálogo global de recursos (selector, panel dedicado) → Etapa 7
- Tabla `user_groups`, `process_access` → Etapa 5
- Avatar / "Actualizado por" en UI → Etapa 6
- Cambiar ningún otro tipo en `src/domain/types.ts`
---
## Scripts SQL — crear en `supabase/migrations/`
### `005_create_resources.sql`
```sql
-- Catálogo global de recursos — sin process_id
CREATE TABLE IF NOT EXISTS public.resources (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
type text NOT NULL CHECK (type IN ('role','person','system','equipment','supply')),
cost_per_hour numeric NOT NULL DEFAULT 0,
created_by uuid REFERENCES public.users(id),
updated_by uuid REFERENCES public.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.resources ENABLE ROW LEVEL SECURITY;
-- Todos los autenticados pueden leer y escribir recursos (catálogo compartido)
CREATE POLICY "all_resources" ON public.resources
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);
```
### `006_create_activities.sql`
```sql
CREATE TABLE IF NOT EXISTS public.activities (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE,
bpmn_element_id text NOT NULL,
name text NOT NULL DEFAULT '',
type text NOT NULL DEFAULT 'task' CHECK (type IN ('task','subprocess')),
direct_cost_fixed numeric NOT NULL DEFAULT 0,
execution_time_minutes numeric NOT NULL DEFAULT 0,
automatable boolean NOT NULL DEFAULT false,
automated_cost_fixed numeric NOT NULL DEFAULT 0,
automated_time_minutes numeric NOT NULL DEFAULT 0,
created_by uuid REFERENCES public.users(id),
updated_by uuid REFERENCES public.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS activities_process_bpmn_idx
ON public.activities(process_id, bpmn_element_id);
ALTER TABLE public.activities ENABLE ROW LEVEL SECURITY;
CREATE POLICY "all_activities" ON public.activities
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);
```
### `007_create_activity_resource_assignments.sql`
```sql
-- Reemplaza los arrays embebidos assignedResources / automatedAssignedResources
CREATE TABLE IF NOT EXISTS public.activity_resource_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
activity_id uuid NOT NULL REFERENCES public.activities(id) ON DELETE CASCADE,
resource_id uuid NOT NULL REFERENCES public.resources(id) ON DELETE CASCADE,
utilization_minutes numeric NOT NULL DEFAULT 0,
units numeric NOT NULL DEFAULT 1,
scenario text NOT NULL DEFAULT 'actual' CHECK (scenario IN ('actual','automated'))
);
ALTER TABLE public.activity_resource_assignments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "all_assignments" ON public.activity_resource_assignments
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);
```
### `008_create_gateways.sql`
```sql
CREATE TABLE IF NOT EXISTS public.gateways (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE,
bpmn_element_id text NOT NULL,
gateway_type text NOT NULL CHECK (gateway_type IN ('exclusive','parallel','inclusive','event-based')),
branches jsonb NOT NULL DEFAULT '[]',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS gateways_process_bpmn_idx
ON public.gateways(process_id, bpmn_element_id);
ALTER TABLE public.gateways ENABLE ROW LEVEL SECURITY;
CREATE POLICY "all_gateways" ON public.gateways
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);
```
**Ejecutar en orden (005 → 006 → 007 → 008) en Supabase SQL Editor antes de continuar. Parar si alguno falla.**
---
## Modificación de tipo TypeScript — `src/domain/types.ts`
Hacer `processId` opcional en la interfaz `Resource`:
```typescript
// ANTES:
export interface Resource {
id: UUID
processId: UUID // ← campo requerido
name: string
type: ResourceType
costPerHour: number
}
// DESPUÉS:
export interface Resource {
id: UUID
processId?: UUID // ← opcional (backward compat con tests Dexie existentes)
name: string
type: ResourceType
costPerHour: number
}
```
**Solo este cambio en `types.ts`**. No modificar ninguna otra interfaz.
---
## Nuevos repositorios — `src/persistence/supabase/`
### `resource-repo.ts`
Catálogo global — no filtra por proceso.
```typescript
export const supabaseResourceRepo = {
async save(resource: Resource, userId: string): Promise<void>
// upsert: { id, name, type, cost_per_hour, updated_by: userId, updated_at: now() }
// en insert: también created_by: userId
// NO escribir processId en la DB
async getAll(): Promise<Resource[]>
// SELECT * FROM resources ORDER BY name
// from_db: { id, name, type: row.type, costPerHour: row.cost_per_hour }
// processId NO se popula en el objeto retornado (queda undefined)
async delete(resourceId: string): Promise<void>
// DELETE FROM resources WHERE id = resourceId
}
```
### `activity-repo.ts`
Las actividades tienen asignaciones embebidas en el tipo TypeScript pero normalizadas en la DB.
```typescript
export const supabaseActivityRepo = {
async save(activity: Activity, userId: string): Promise<void>
// 1. Upsert en activities (sin los arrays de recursos)
// 2. DELETE FROM activity_resource_assignments WHERE activity_id = activity.id
// 3. INSERT assignments de activity.assignedResources (scenario='actual')
// 4. INSERT assignments de activity.automatedAssignedResources (scenario='automated')
async saveMany(activities: Activity[], userId: string): Promise<void>
// Loop de save() — performance no crítica para import
async getByProcess(processId: string): Promise<Activity[]>
// SELECT activities.*, activity_resource_assignments(*) WHERE process_id = processId
// Reconstruir Activity con:
// assignedResources: assignments.filter(a => a.scenario === 'actual').map(toAssignment)
// automatedAssignedResources: assignments.filter(a => a.scenario === 'automated').map(toAssignment)
async getByBpmnElementId(processId: string, bpmnElementId: string): Promise<Activity | undefined>
// SELECT con WHERE process_id = ? AND bpmn_element_id = ? + join assignments
async deleteByProcess(processId: string): Promise<void>
// DELETE WHERE process_id = processId (assignments se eliminan por CASCADE)
}
```
**Mapeo Activity TypeScript ↔ PostgreSQL:**
| TypeScript | PostgreSQL |
|---|---|
| `id` | `id` |
| `processId` | `process_id` |
| `bpmnElementId` | `bpmn_element_id` |
| `name` | `name` |
| `type` | `type` |
| `directCostFixed` | `direct_cost_fixed` |
| `executionTimeMinutes` | `execution_time_minutes` |
| `automatable` | `automatable` |
| `automatedCostFixed` | `automated_cost_fixed` |
| `automatedTimeMinutes` | `automated_time_minutes` |
| `assignedResources` | via `activity_resource_assignments` WHERE scenario='actual' |
| `automatedAssignedResources` | via `activity_resource_assignments` WHERE scenario='automated' |
### `gateway-repo.ts`
```typescript
export const supabaseGatewayRepo = {
async save(gateway: GatewayConfig): Promise<void>
// upsert: { id, process_id, bpmn_element_id, gateway_type, branches: JSON.stringify(gateway.branches) }
async saveMany(gateways: GatewayConfig[]): Promise<void>
// Loop de save()
async getByProcess(processId: string): Promise<GatewayConfig[]>
// SELECT WHERE process_id = processId
// from_db: branches: JSON.parse(row.branches) as GatewayBranch[]
async deleteByProcess(processId: string): Promise<void>
// DELETE WHERE process_id (CASCADE ya lo maneja, pero mantener para explicitez)
}
```
**Mapeo GatewayConfig:**
| TypeScript | PostgreSQL |
|---|---|
| `id` | `id` |
| `processId` | `process_id` |
| `bpmnElementId` | `bpmn_element_id` |
| `gatewayType` | `gateway_type` |
| `branches` (array) | `branches` (jsonb) — `JSON.stringify` / `JSON.parse` |
---
## Actualización de stores y componentes
### `src/store/process-store.ts`
Reemplazar todas las llamadas a repos Dexie con los nuevos repos Supabase.
**`loadProcess`**: cambiar `resourceRepo.getByProcess(processId)``supabaseResourceRepo.getAll()`
Los recursos se cargan globalmente. El store los tiene todos disponibles para el workspace.
**`setCurrentProcess`**: igual — cargar recursos con `getAll()`
**`updateActivity`**: `activityRepo.save(activity)``supabaseActivityRepo.save(activity, userId)`
**`updateGateway`**: `gatewayRepo.save(gateway)``supabaseGatewayRepo.save(gateway)`
(gateways no tienen userId — no hay created_by/updated_by en el schema de gateways)
**`addResource` / `updateResource`**: `resourceRepo.save(resource)``supabaseResourceRepo.save(resource, userId)`
**Al crear un recurso, NO incluir `processId`** en el objeto que se pasa al repo.
**`deleteResource`**: `resourceRepo.delete(resourceId)``supabaseResourceRepo.delete(resourceId)`
### `src/persistence/supabase/process-repo.ts` — actualizar `delete`
Después de Etapa 3, activities/gateways/resources tienen CASCADE en Supabase.
Actualizar el método `delete` para que limpie Dexie solo de simulaciones:
```typescript
async delete(id: string): Promise<void> {
// Supabase CASCADE elimina automáticamente: activities, gateways, activity_resource_assignments
await supabase.from('processes').delete().eq('id', id)
// TODO Sprint 4 Etapa 4: eliminar limpieza Dexie cuando simulaciones migren a Supabase
await db.transaction('rw', [db.simulations], async () => {
await db.simulations.where('processId').equals(id).delete()
})
// Nota: activities, gateways, resources ya NO están en Dexie — no limpiar
}
```
### `src/features/report/useReportData.ts`
Cambiar `resourceRepo.getByProcess(processId)``supabaseResourceRepo.getAll()`
### `src/features/workspace/ResourcesPanel.tsx`
Al crear un nuevo recurso, **no incluir `processId`** en el objeto:
```typescript
// ANTES:
const newResource: Resource = {
id: crypto.randomUUID(),
processId: currentProcess.id, // ← eliminar esta línea
name: ...,
type: ...,
costPerHour: ...,
}
// DESPUÉS:
const newResource: Resource = {
id: crypto.randomUUID(),
name: ...,
type: ...,
costPerHour: ...,
}
```
No hacer otros cambios de UX en ResourcesPanel — el rediseño completo (selección desde catálogo) es Etapa 7.
### `src/features/import/ImportPage.tsx`
Si usa `activityRepo.saveMany()` o `gatewayRepo.saveMany()`, actualizar a los repos Supabase.
Asegurarse de que el `userId` se pasa correctamente (el componente tiene acceso via `useAuth()`).
---
## Criterios de validación
- [ ] `npm run build` sin errores TypeScript
- [ ] `npm run test` — 561 tests siguen verdes
- [ ] Importar un BPMN → el proceso se carga correctamente en el workspace
- [ ] Editar el costo de una actividad → persiste después de recargar la página
- [ ] Editar la probabilidad de un gateway → persiste después de recargar
- [ ] Crear un recurso → aparece en Supabase tabla `resources` sin `process_id`
- [ ] El mismo recurso es visible en todos los procesos (catálogo global)
- [ ] Eliminar un proceso → sus actividades y gateways desaparecen de Supabase (CASCADE)
- [ ] El recurso creado NO desaparece al eliminar el proceso (es global)
- [ ] En Supabase tabla `activities`: aparecen las actividades con `process_id` correcto
- [ ] En Supabase tabla `activity_resource_assignments`: aparecen las asignaciones con `scenario`
- [ ] Validación visual del director: workspace funcional (canvas + panel de actividad)
---
## Reporte esperado al completar
1. Lista de archivos creados/modificados
2. Resultado de `npm run test` (número de tests, pass/fail)
3. Resultado de `npm run build`
4. Screenshot del workspace con actividades cargadas
5. Screenshot de Supabase tabla `resources` (sin columna `process_id`)
6. Screenshot de Supabase tabla `activity_resource_assignments` con al menos una fila
7. Cualquier decisión de iniciativa fuera del scope (clasificar nivel 1/2/3)
---
## Notas arquitectónicas
### Los tests de Dexie siguen verdes
`tests/integration/indexeddb-persistence.test.ts` usa el `resourceRepo` de Dexie con `processId`. Esos tests deben seguir pasando sin cambios — el `resourceRepo` Dexie todavía existe en `repositories.ts`, simplemente ya no es usado por la producción. No borrar ni modificar ese archivo en esta etapa.
### Patrón userId establecido
El patrón de Etapa 2 aplica acá: repos reciben `userId` como parámetro, stores lo reciben de los componentes via `useAuth()`. Los tests de componentes que llaman acciones de escritura deben mockear `useAuth`.
Gateways no tienen `created_by`/`updated_by` en el schema — no necesitan `userId` en el repo.
### Catálogo global — impacto en UX
Al cargar el workspace, ahora se cargan TODOS los recursos del equipo (no solo los del proceso). El `ResourcesPanel` los mostrará todos. Esto es el comportamiento correcto del catálogo global — el rediseño visual de ese panel es Etapa 7.

View File

@@ -0,0 +1,153 @@
# Sprint 4 — Etapa 5B: Fix race condition auth/fetch en LibraryPage
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind.
Deploy activo en `https://inq-roi.inqualityhq.com` (Dokploy/VPS).
El deploy muestra la biblioteca con 0 procesos a pesar de que existen 6 en Supabase con `owner_id` correcto, RLS configurado y GRANT EXECUTE en `is_platform_admin()` verificado. Los procesos NO son un problema de datos ni de permisos PostgreSQL. El problema es de timing en el cliente.
---
## Diagnóstico
**Síntoma observado:** "la primera vez veo invocaciones a Supabase, después no".
**Causa raíz identificada:** Race condition entre la inicialización de la sesión y el fetch de procesos.
El flujo actual es:
1. `LibraryPage` monta → `useEffect` llama `load()` inmediatamente (sin guard de auth)
2. `load()` llama `supabaseProcessRepo.getAll()` — también sin guard
3. `getAll()` hace `supabase.from('processes').select('*')` directo
4. En Supabase-js v2, `_initializeAuthFromStorage()` es **async** — el cliente puede no tener la sesión lista cuando el request sale
5. El request sale sin JWT → `auth.uid()` en PostgreSQL devuelve NULL → RLS bloquea todo → 0 filas
6. Milisegundos después, `onAuthStateChange` dispara con `INITIAL_SESSION` → sesión establecida
7. Pero `load()` ya corrió y **no vuelve a correr** (sus deps no cambiaron)
En `AuthContext.tsx`:
```tsx
// user se pre-popula desde localStorage SÍNCRONAMENTE — esto está bien
const [user, setUser] = useState<AuthUser | null>(
isOAuthRedirect ? null : getUserFromStorage()
)
```
Pero el objeto `supabase` (el cliente de supabase-js) es una instancia separada que inicializa su sesión interna de forma async. El hecho de que el AuthContext tenga el user en estado React no garantiza que el cliente Supabase tenga el token listo para adjuntar al request.
En `LibraryPage.tsx`:
```tsx
// Se llama SIN verificar si el cliente Supabase tiene sesión activa
useEffect(() => {
load()
}, [load])
```
---
## Alcance estricto — qué entra en esta etapa
### 1. Agregar logging diagnóstico (temporal, para verificar)
En `src/persistence/supabase/process-repo.ts`, en el método `getAll()`, agregar antes del query:
```typescript
const { data: sessionData } = await supabase.auth.getSession()
console.log('[process-repo] getAll session:', {
hasSession: !!sessionData.session,
userId: sessionData.session?.user?.id ?? 'NULL',
tokenPrefix: sessionData.session?.access_token?.substring(0, 30) ?? 'NO TOKEN'
})
```
Este log confirma si el problema es efectivamente que el cliente no tiene sesión al momento del fetch.
### 2. Fix principal — esperar sesión antes de fetch
**Opción A (recomendada): Guard en LibraryPage**
En `src/features/library/LibraryPage.tsx`, cambiar el useEffect que llama `load()`:
```tsx
// ANTES:
useEffect(() => {
load()
}, [load])
// DESPUÉS:
useEffect(() => {
if (!user) return // no fetchear hasta tener usuario confirmado
load()
}, [load, user])
```
Esto garantiza que `load()` solo corre cuando `user` (que viene de `AuthContext`) está definido. Como `AuthContext` llama `onAuthStateChange` que espera la confirmación de Supabase, cuando `user` se setea el cliente ya tiene la sesión lista.
**Opción B (alternativa si A no alcanza): Await getSession en getAll**
Si la Opción A no es suficiente (el cliente aún no tiene el token aunque user exista en React), agregar al inicio de `getAll()` en `process-repo.ts`:
```typescript
async getAll(): Promise<Process[]> {
// Garantiza que el cliente Supabase tenga la sesión antes de la query
await supabase.auth.getSession()
const { data, error } = await supabase
.from('processes')
.select('*')
.order('updated_at', { ascending: false })
if (error) throw error
return (data ?? []).map(fromRow)
},
```
`getSession()` en Supabase-js v2 espera a que `_initializeAuthFromStorage()` complete antes de retornar. Es seguro llamarlo múltiples veces — es idempotente.
**Implementar ambas opciones** — son complementarias y defensivas.
### 3. Verificar si el mismo problema afecta otros repos
Si la Opción B se aplica en `process-repo.ts`, verificar si los demás repos (`activity-repo.ts`, `resource-repo.ts`, `simulation-repo.ts`, `gateway-repo.ts`) tienen el mismo patrón `supabase.from(...).select(...)` sin await previo de getSession. Si sí, aplicar el mismo fix en todos los métodos de lectura inicial (los que se llaman al montar componentes, no los que se llaman por acción del usuario).
### 4. Remover logging diagnóstico antes de commit
Una vez verificado que el fix funciona (los procesos aparecen en la biblioteca), remover los `console.log` agregados en el paso 1.
---
## Qué NO entra en esta etapa
- Cambios en las RLS policies (ya están correctas)
- Cambios en el AuthContext más allá del guard en LibraryPage
- Features de UI de Etapa 5 (ownerId, badges, toggle Todos/Míos) — esas van en Etapa 5 original
- Ningún cambio en PDF, simulación o workspace
---
## Orden de ejecución
1. Agregar logging en `process-repo.getAll()` para confirmar diagnóstico
2. Aplicar Opción A: guard `if (!user) return` en LibraryPage useEffect
3. Aplicar Opción B: `await supabase.auth.getSession()` en `process-repo.getAll()`
4. Verificar otros repos (paso 3 del alcance)
5. `npm run build` — debe pasar sin errores TypeScript
6. `npm run test` — debe mantener 521+ tests verdes
7. Remover logging diagnóstico
8. Commit: `fix(sprint-4/etapa-5b): race condition auth/fetch en LibraryPage`
---
## Criterios de validación
- [ ] Log diagnóstico confirma que antes del fix `hasSession: false` en la primera llamada
- [ ] Después de los fixes, el deploy en `inq-roi.inqualityhq.com` muestra los procesos en la biblioteca
- [ ] `npm run build` limpio
- [ ] 521+ tests verdes
- [ ] No hay `console.log` de diagnóstico en el código final
- [ ] **Validación visual del director** antes del OK formal: la biblioteca carga los procesos
---
## Nota de contexto para el fix
El objeto `supabase` (singleton creado en `src/persistence/supabase/client.ts` o similar) y el estado React `user` en `AuthContext` son dos sistemas paralelos. El `AuthContext` puede tener `user !== null` (leído de localStorage síncronamente) mientras que el cliente Supabase todavía está en proceso de restaurar la sesión internamente. El `await supabase.auth.getSession()` actúa como barrera de sincronización entre ambos sistemas.

View File

@@ -0,0 +1,251 @@
# Sprint 4 — Etapa 5: Modelo de privacidad — RLS granular + UI de visibilidad
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind.
Etapas 14 completadas: auth Google, procesos/grupos/settings/actividades/gateways/recursos/simulaciones en Supabase, Dexie eliminado.
**Antes de arrancar esta etapa:** commitear Etapas 3 y 4 juntas en un único commit con mensaje `feat(sprint-4/etapa-3-4): migración actividades/gateways/recursos/simulaciones a Supabase + limpieza Dexie`.
Esta etapa reemplaza la política RLS amplia `authenticated_all_processes` por un modelo granular basado en ownership y acceso explícito, e incorpora los elementos de UI correspondientes en la biblioteca.
---
## Prerequisitos — verificar antes de empezar
- Scripts 001009 ejecutados en Supabase Cloud
- `src/auth/AuthContext.tsx` con `useAuth()` devolviendo `{ user }` con al menos `user.id`
- `src/persistence/supabase/process-repo.ts` existente
---
## Alcance estricto — qué entra en esta etapa
### 1. Nuevos scripts SQL (crear en `supabase/migrations/`)
**`010_create_user_groups.sql`** — tablas de grupos de usuarios:
```sql
CREATE TABLE IF NOT EXISTS public.user_groups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_by uuid REFERENCES public.users(id),
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.user_groups ENABLE ROW LEVEL SECURITY;
CREATE POLICY "read_groups" ON public.user_groups
FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "manage_groups" ON public.user_groups
FOR ALL USING (
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin')
OR created_by = auth.uid()
);
CREATE TABLE IF NOT EXISTS public.group_memberships (
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
group_id uuid NOT NULL REFERENCES public.user_groups(id) ON DELETE CASCADE,
role text NOT NULL DEFAULT 'member' CHECK (role IN ('owner','member')),
PRIMARY KEY (user_id, group_id)
);
ALTER TABLE public.group_memberships ENABLE ROW LEVEL SECURITY;
CREATE POLICY "read_memberships" ON public.group_memberships
FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "manage_memberships" ON public.group_memberships
FOR ALL USING (
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin')
OR EXISTS (
SELECT 1 FROM public.user_groups
WHERE id = group_memberships.group_id AND created_by = auth.uid()
)
);
```
**`011_create_process_access.sql`** — tabla de acceso explícito a procesos:
```sql
CREATE TABLE IF NOT EXISTS public.process_access (
process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE,
grantee_type text NOT NULL CHECK (grantee_type IN ('user','group')),
grantee_id uuid NOT NULL,
permission text NOT NULL DEFAULT 'view' CHECK (permission IN ('view','edit')),
PRIMARY KEY (process_id, grantee_type, grantee_id)
);
ALTER TABLE public.process_access ENABLE ROW LEVEL SECURITY;
CREATE POLICY "read_process_access" ON public.process_access
FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "manage_process_access" ON public.process_access
FOR ALL USING (
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin')
OR EXISTS (
SELECT 1 FROM public.processes
WHERE id = process_access.process_id AND owner_id = auth.uid()
)
);
```
**`012_update_processes_rls.sql`** — reemplazar política amplia con políticas granulares:
```sql
-- Helper: retorna true si el usuario actual es platform_admin
CREATE OR REPLACE FUNCTION public.is_platform_admin()
RETURNS boolean
LANGUAGE sql SECURITY DEFINER
AS $$
SELECT EXISTS (
SELECT 1 FROM public.users
WHERE id = auth.uid() AND platform_role = 'platform_admin'
);
$$;
-- Eliminar política amplia de Sprint 4 Etapa 2
DROP POLICY IF EXISTS "authenticated_all_processes" ON public.processes;
-- Políticas granulares
CREATE POLICY "select_processes" ON public.processes
FOR SELECT USING (
public.is_platform_admin()
OR owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM public.process_access
WHERE process_id = processes.id
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR (grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
))
)
)
);
CREATE POLICY "insert_processes" ON public.processes
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "update_processes" ON public.processes
FOR UPDATE USING (
public.is_platform_admin()
OR owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM public.process_access
WHERE process_id = processes.id
AND permission = 'edit'
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR (grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
))
)
)
);
CREATE POLICY "delete_processes" ON public.processes
FOR DELETE USING (public.is_platform_admin() OR owner_id = auth.uid());
```
---
### 2. Tipo `Process` — agregar `ownerId`
En `src/domain/types.ts`, agregar el campo al final de la interfaz `Process`:
```typescript
export interface Process {
// ... campos existentes sin cambios ...
ownerId: string // 🆕 Sprint 4 — owner del proceso (auth.uid() al crear)
}
```
---
### 3. Repositorio de procesos — mapear `ownerId`
En `src/persistence/supabase/process-repo.ts`:
- `fromRow`: agregar `ownerId: (row.owner_id as string) ?? ''`
- `toRow`: el campo `owner_id` ya se escribe correctamente; verificar que no se sobreescriba en updates (en update, `owner_id` no debe cambiar — usar el valor del proceso existente, no `userId` del caller)
Agregar método para gestionar visibilidad:
```typescript
async setShared(processId: string, shared: boolean): Promise<void>
// si shared=true: no hace nada (la visibilidad para platform_admin ya está por RLS)
// si shared=false: no hace nada en Sprint 4 (sin guests aún)
// En Sprint 4 este método existe para no romper la UI — implementación no-op con comentario explicativo
// La implementación real (insertar/borrar en process_access) llega cuando existan guests
```
---
### 4. LibraryPage — UI de visibilidad
Modificar `src/features/library/LibraryPage.tsx`:
**a) Toggle "Todos / Míos"**
Agregar estado local `filter: 'all' | 'mine'`. Aplicar antes del render:
- `'mine'`: mostrar solo procesos donde `process.ownerId === currentUser.id`
- `'all'`: sin filtro adicional
UI: dos botones pill en la barra de herramientas existente, junto al buscador. Usar los colores del sistema (naranja InQ `#F59845` para el activo, borde neutro para el inactivo).
**b) Badge de visibilidad en tarjetas de proceso**
En la tarjeta de cada proceso, agregar un badge pequeño:
- Proceso propio (`ownerId === currentUser.id`) → mostrar ícono 🔒 con tooltip "Solo vos"
- Proceso de otro usuario → mostrar ícono 👤 con tooltip "De otro consultor"
En Sprint 4 todos son `platform_admin` y ven todo, pero el badge refleja el ownership.
**c) Versión con wow — menú de contexto: compartir proceso**
En el menú `MoreVertical` de cada tarjeta propia, agregar opción "Compartir con equipo" (si `ownerId === currentUser.id`). Al hacer click, llama a `setShared(processId, true)` del repo y muestra un toast "Proceso compartido con el equipo". En Sprint 4 es una acción no-op en la DB (el método existe pero no escribe aún), pero la UI y el feedback visual funcionan completos. Agregar ícono `Share2` de Lucide.
---
## Qué NO entra en esta etapa
- Implementación real de `setShared` con escritura en `process_access` → cuando existan guests
- "Actualizado por [nombre]" en tarjetas → Etapa 6
- Header con avatar de usuario → Etapa 6
- UX de catálogo de recursos → Etapa 7
- Tabla `user_groups` en la UI (gestión de grupos) → Sprint futuro
- Ningún cambio en PDF, simulación o workspace
---
## Orden de ejecución
1. Crear los tres archivos SQL en `supabase/migrations/`
2. Modificar `src/domain/types.ts` — agregar `ownerId`
3. Modificar `src/persistence/supabase/process-repo.ts` — mapear `ownerId` + agregar `setShared`
4. Modificar `src/features/library/LibraryPage.tsx` — toggle + badges + menú compartir
5. `npm run build` — debe pasar sin errores TypeScript
6. `npm run test` — debe mantener 521+ tests verdes
---
## Criterios de validación
- [ ] Archivos SQL 010, 011, 012 creados correctamente en `supabase/migrations/`
- [ ] `Process.ownerId` existe en `types.ts`
- [ ] `fromRow` en process-repo mapea `owner_id``ownerId`
- [ ] `setShared` existe en process-repo (puede ser no-op con comentario)
- [ ] LibraryPage tiene toggle "Todos / Míos" funcional
- [ ] LibraryPage muestra badge de ownership en cada tarjeta
- [ ] Build limpio sin errores TypeScript
- [ ] 521+ tests verdes
- [ ] **Validación visual del director** antes del OK formal
---
## Nota sobre los scripts SQL
Los tres scripts (010, 011, 012) se crean en esta etapa pero **el director los ejecuta manualmente** en Supabase Studio SQL Editor antes del QA visual, en orden numérico. Claude Code no tiene acceso al dashboard de Supabase.

View File

@@ -13,16 +13,62 @@ const AuthContext = createContext<AuthContextValue | null>(null)
const authService = new SupabaseAuthService()
// Lee el usuario almacenado en localStorage SIN hacer llamadas de red.
// Supabase v2 usa la clave sb-{project_ref}-auth-token.
// Permite renderizar la app inmediatamente sin esperar la verificación async de sesión.
function getUserFromStorage(): AuthUser | null {
try {
const key = Object.keys(localStorage).find(
(k) => k.startsWith('sb-') && k.endsWith('-auth-token')
)
if (!key) return null
const stored = JSON.parse(localStorage.getItem(key) ?? 'null')
const u = stored?.user
if (!u?.id) return null
const email = (u.email as string) ?? ''
return {
id: u.id as string,
email,
name: (u.user_metadata?.full_name as string) ?? email,
avatarUrl: u.user_metadata?.avatar_url as string | undefined,
platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest',
}
} catch {
return null
}
}
// Detecta si la URL tiene un callback de OAuth (token en el hash o code en query).
// En ese caso NO usar localStorage — dejar que Supabase procese el hash primero.
function hasOAuthCallback(): boolean {
const hash = window.location.hash
const search = window.location.search
return (
hash.includes('access_token') ||
hash.includes('refresh_token') ||
search.includes('code=')
)
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null)
const [loading, setLoading] = useState(true)
// Si hay un OAuth callback en la URL, no leer localStorage (el token aún no está ahí).
// Supabase lo procesará en onAuthStateChange y actualizará el estado.
const isOAuthRedirect = hasOAuthCallback()
const [user, setUser] = useState<AuthUser | null>(
isOAuthRedirect ? null : getUserFromStorage()
)
// Si es un OAuth redirect, mantener loading=true hasta que Supabase procese el token.
const [loading, setLoading] = useState(isOAuthRedirect)
useEffect(() => {
// Verificar y sincronizar con Supabase en background.
// Si la sesión expiró o fue revocada, onAuthStateChange llamará callback(null)
// y el router redirigirá a /login.
const unsubscribe = authService.onAuthStateChange((u) => {
setUser(u)
setLoading(false)
})
return unsubscribe
return () => unsubscribe()
}, [])
const signIn = useCallback(() => authService.signInWithGoogle(), [])

View File

@@ -24,14 +24,19 @@ export class SupabaseAuthService implements AuthService {
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
// Siempre llamar callback — incluso si falla algo interno
try {
if (!session?.user) {
callback(null)
// INITIAL_SESSION null es transitorio: Supabase no pudo verificar la sesión
// (red lenta, cold start, timeout). Si hay sesión en localStorage, no forzar logout.
// Solo cerrar sesión en eventos explícitos (SIGNED_OUT, USER_DELETED, etc).
if (event !== 'INITIAL_SESSION') {
callback(null)
}
return
}
if (event === 'SIGNED_IN') {
// Primer login: upsert del usuario en la tabla users
const email = session.user.email ?? ''
const role = email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest'
const { error: upsertError } = await supabase.from('users').upsert(
@@ -46,19 +51,14 @@ export class SupabaseAuthService implements AuthService {
if (upsertError) console.error('Error al crear usuario en DB:', upsertError)
}
const user = await this.buildAuthUser(session.user)
// buildAuthUser es sincrónico — sin DB query, usa metadatos de la sesión
const user = this.buildAuthUser(session.user)
callback(user)
} catch (err) {
console.error('Error en onAuthStateChange:', err)
// Garantizar que loading se desbloquea incluso en error
if (session?.user) {
callback({
id: session.user.id,
email: session.user.email ?? '',
name: session.user.user_metadata?.full_name as string ?? session.user.email ?? '',
platformRole: 'guest',
})
} else {
callback(this.buildAuthUser(session.user))
} else if (event !== 'INITIAL_SESSION') {
callback(null)
}
}
@@ -68,20 +68,16 @@ export class SupabaseAuthService implements AuthService {
return () => subscription.unsubscribe()
}
private async buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): Promise<AuthUser> {
// Sincrónico: deriva el usuario de los metadatos de sesión sin consultar la DB.
// El rol se deriva del dominio del email (misma lógica que el upsert en SIGNED_IN).
private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): AuthUser {
const email = supabaseUser.email ?? ''
const { data: row } = await supabase
.from('users')
.select('name, avatar_url, platform_role')
.eq('id', supabaseUser.id)
.single()
return {
id: supabaseUser.id,
email,
name: row?.name ?? supabaseUser.user_metadata?.full_name as string ?? email,
avatarUrl: row?.avatar_url ?? supabaseUser.user_metadata?.avatar_url as string ?? undefined,
platformRole: (row?.platform_role ?? 'guest') as AuthUser['platformRole'],
name: supabaseUser.user_metadata?.full_name as string ?? email,
avatarUrl: supabaseUser.user_metadata?.avatar_url as string ?? undefined,
platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest',
}
}
}

View File

@@ -18,6 +18,7 @@ export interface Process {
updatedAt: number
groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar")
tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda
ownerId: string // 🆕 Sprint 4 — owner del proceso (auth.uid() al crear)
}
export type ActivityType = 'task' | 'subprocess'
@@ -63,7 +64,7 @@ export type ResourceType = 'role' | 'person' | 'system' | 'equipment' | 'supply'
export interface Resource {
id: UUID
processId: UUID
processId?: UUID // opcional — recursos son catálogo global desde Etapa 3
name: string
type: ResourceType
costPerHour: number

View File

@@ -4,11 +4,13 @@ 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 { activityRepo, gatewayRepo } from '@/persistence/repositories'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
import { supabaseGatewayRepo } from '@/persistence/supabase/gateway-repo'
import type { Process, Activity, GatewayConfig } from '@/domain/types'
import { bpmnTypeToGatewayType } from '@/domain/types'
import { useToast } from '@/components/ui/use-toast'
import { useAuth } from '@/auth/AuthContext'
import { AppFooter } from '@/components/AppFooter'
import { AppHeader } from '@/components/AppHeader'
@@ -36,6 +38,7 @@ const SAMPLE_PROCESSES = [
export function ImportPage() {
const navigate = useNavigate()
const { toast } = useToast()
const { user } = useAuth()
const [isDragging, setIsDragging] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [loadingSampleId, setLoadingSampleId] = useState<string | null>(null)
@@ -77,6 +80,7 @@ export function ImportPage() {
updatedAt: now,
groupId: targetGroupId,
tags: [],
ownerId: user!.id,
}
const activityElements = extractActivityElements(graph)
@@ -112,10 +116,20 @@ export function ImportPage() {
}
})
await Promise.all([
supabaseProcessRepo.save(process),
activityRepo.saveMany(activities),
gatewayRepo.saveMany(gateways),
await Promise.race([
(async () => {
await supabaseProcessRepo.save(process, user!.id)
await Promise.all([
supabaseActivityRepo.saveMany(activities, user!.id),
supabaseGatewayRepo.saveMany(gateways),
])
})(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('Timeout: Supabase no respondió en 20s — verificá tu conexión')),
20_000,
)
),
])
toast({

View File

@@ -3,8 +3,11 @@ import { useNavigate } from '@tanstack/react-router'
import {
Upload, Search, Plus, Building2, FolderX, GitBranch,
Clock, TrendingUp, BarChart2, FileX2, X, Settings,
MoreVertical, Trash2, Share2, Lock, User,
} from 'lucide-react'
import { useLibraryStore } from '@/store/library-store'
import { useAuth } from '@/auth/AuthContext'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { formatCurrency } from '@/lib/format'
import type { Process, ProcessGroup, Simulation } from '@/domain/types'
import { AppHeader } from '@/components/AppHeader'
@@ -41,6 +44,48 @@ function matchesTags(p: Process, activeTags: string[]): boolean {
return activeTags.every((t) => p.tags.includes(t))
}
// ─── Ownership Badge ──────────────────────────────────────────────────────────
function OwnershipBadge({ process, currentUserId }: { process: Process; currentUserId: string }) {
const isOwn = process.ownerId === currentUserId
return (
<div
className="flex-shrink-0 flex items-center"
title={isOwn ? 'Solo vos' : 'De otro consultor'}
>
{isOwn
? <Lock className="h-3 w-3 text-muted-foreground/60" />
: <User className="h-3 w-3 text-muted-foreground/60" />
}
</div>
)
}
// ─── Toast ────────────────────────────────────────────────────────────────────
function useToast() {
const [message, setMessage] = useState<string | null>(null)
function show(msg: string) {
setMessage(msg)
setTimeout(() => setMessage(null), 2800)
}
return { message, show }
}
function Toast({ message }: { message: string | null }) {
if (!message) return null
return (
<div
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2.5 rounded-lg text-[13px] font-medium text-white shadow-lg transition-all"
style={{ background: '#15803d' }}
>
{message}
</div>
)
}
// ─── KPI Badge ────────────────────────────────────────────────────────────────
function KpiBadge({ process, simulation }: { process: Process; simulation: Simulation | null | undefined }) {
@@ -78,18 +123,34 @@ function KpiBadge({ process, simulation }: { process: Process; simulation: Simul
// ─── Process Card ─────────────────────────────────────────────────────────────
function ProcessCard({ process, simulation, showGroupChip, groupName }: {
function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, onShared }: {
process: Process
simulation: Simulation | null | undefined
showGroupChip?: boolean
groupName?: string
onDelete?: () => void
currentUserId: string
onShared?: () => void
}) {
const navigate = useNavigate()
const [menuState, setMenuState] = useState<null | 'menu' | 'confirm'>(null)
const isOwn = process.ownerId === currentUserId
// Cierra el menú al hacer click fuera de él
useEffect(() => {
if (!menuState) return
function handleDocClick() { setMenuState(null) }
document.addEventListener('click', handleDocClick)
return () => document.removeEventListener('click', handleDocClick)
}, [menuState])
return (
<div
className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card hover:border-border/80 hover:shadow-sm transition-all cursor-pointer"
onClick={() => navigate({ to: '/workspace/$processId', params: { processId: process.id } })}
className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card hover:border-border/80 hover:shadow-sm transition-all cursor-pointer group"
onClick={() => {
if (menuState) { setMenuState(null); return }
navigate({ to: '/workspace/$processId', params: { processId: process.id } })
}}
>
{/* Ícono */}
<div className="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center"
@@ -99,7 +160,10 @@ function ProcessCard({ process, simulation, showGroupChip, groupName }: {
{/* Info central */}
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium truncate">{process.name || 'Sin nombre'}</p>
<div className="flex items-center gap-1.5">
<p className="text-[13px] font-medium truncate">{process.name || 'Sin nombre'}</p>
<OwnershipBadge process={process} currentUserId={currentUserId} />
</div>
<div className="flex items-center gap-1 mt-0.5">
<Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-[11px] text-muted-foreground">Modificado {relativeDate(process.updatedAt)}</span>
@@ -125,6 +189,74 @@ function ProcessCard({ process, simulation, showGroupChip, groupName }: {
<div className="flex-shrink-0 min-w-[90px]">
<KpiBadge process={process} simulation={simulation} />
</div>
{/* Menú de opciones (⋮) — en el flujo flex, a la derecha del KPI */}
{onDelete && (
<div
className="relative flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
<button
className={`p-1 rounded hover:bg-secondary text-muted-foreground hover:text-foreground transition-all ${menuState === null ? 'opacity-0 group-hover:opacity-100' : 'opacity-100'}`}
onClick={(e) => { e.stopPropagation(); setMenuState(menuState === 'menu' ? null : 'menu') }}
title="Opciones"
>
<MoreVertical className="h-3.5 w-3.5" />
</button>
{menuState === 'menu' && (
<div
className="absolute right-0 top-7 z-20 bg-card border border-border rounded-lg shadow-lg py-1 min-w-[160px]"
onClick={(e) => e.stopPropagation()}
>
{isOwn && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-foreground hover:bg-secondary transition-colors"
onClick={async (e) => {
e.stopPropagation()
await supabaseProcessRepo.setShared(process.id, true)
setMenuState(null)
onShared?.()
}}
>
<Share2 className="h-3.5 w-3.5" />
Compartir con equipo
</button>
)}
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-destructive hover:bg-destructive/10 transition-colors"
onClick={(e) => { e.stopPropagation(); setMenuState('confirm') }}
>
<Trash2 className="h-3.5 w-3.5" />
Eliminar
</button>
</div>
)}
{menuState === 'confirm' && (
<div
className="absolute right-0 top-6 z-20 bg-card border border-border rounded-lg shadow-lg p-3 min-w-[170px]"
onClick={(e) => e.stopPropagation()}
>
<p className="text-[12px] font-medium mb-2 text-foreground">¿Eliminar proceso?</p>
<p className="text-[11px] text-muted-foreground mb-3">Esta acción no se puede deshacer.</p>
<div className="flex gap-1.5">
<button
className="flex-1 text-[11px] px-2 py-1 rounded font-medium text-white transition-opacity hover:opacity-90"
style={{ background: '#ef4444' }}
onClick={(e) => { e.stopPropagation(); onDelete(); setMenuState(null) }}
>
Eliminar
</button>
<button
className="flex-1 text-[11px] px-2 py-1 rounded border border-border text-muted-foreground hover:bg-secondary transition-colors"
onClick={(e) => { e.stopPropagation(); setMenuState(null) }}
>
Cancelar
</button>
</div>
</div>
)}
</div>
)}
</div>
)
}
@@ -239,28 +371,58 @@ function NewGroupCard({ label, onCreate }: { label: string; onCreate: (name: str
function HomeView({
onNavigateToGroup,
onImport,
ownerFilter,
currentUserId,
onShared,
}: {
onNavigateToGroup: (groupId: string | null) => void
onImport: (groupId?: string) => void
ownerFilter: 'all' | 'mine'
currentUserId: string
onShared: () => void
}) {
const { groups, processes, settings, createGroup } = useLibraryStore()
const { groups, processes, settings, createGroup, isLoading } = useLibraryStore()
const { user } = useAuth()
const [search, setSearch] = useState('')
const [activeTags, setActiveTags] = useState<string[]>([])
const cloud = tagCloudData(processes)
const unclassifiedProcesses = processes.filter((p) => p.groupId == null)
const visibleProcesses = ownerFilter === 'mine'
? processes.filter((p) => p.ownerId === currentUserId)
: processes
const cloud = tagCloudData(visibleProcesses)
const unclassifiedProcesses = visibleProcesses.filter((p) => p.groupId == null)
const isSearching = search.trim().length > 0 || activeTags.length > 0
const filteredProcesses = isSearching
? processes.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
? visibleProcesses.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
: []
function toggleTag(tag: string) {
setActiveTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag])
}
// Empty state: sin grupos ni procesos
// Skeleton mientras carga y aún no hay datos (evita flash de empty state)
if (isLoading && groups.length === 0 && processes.length === 0) {
return (
<div className="space-y-5 animate-pulse">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 rounded-xl bg-slate-100" />
))}
</div>
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="h-14 rounded-xl bg-slate-100" />
))}
</div>
</div>
)
}
// Empty state: sin grupos ni procesos (ya terminó de cargar)
if (groups.length === 0 && processes.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-24 gap-4">
@@ -331,6 +493,8 @@ function HomeView({
process={p}
showGroupChip
groupName={group?.name}
currentUserId={currentUserId}
onShared={onShared}
/>
)
})}
@@ -386,7 +550,7 @@ function HomeView({
{/* Grid de grupos */}
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
{groups.map((group) => {
const groupProcesses = processes.filter((p) => p.groupId === group.id)
const groupProcesses = visibleProcesses.filter((p) => p.groupId === group.id)
const lastUpdated = groupProcesses.length > 0
? Math.max(...groupProcesses.map((p) => p.updatedAt))
: null
@@ -402,7 +566,7 @@ function HomeView({
})}
<NewGroupCard
label={settings.groupLabel}
onCreate={(name) => createGroup(name)}
onCreate={(name) => createGroup(name, user!.id)}
/>
</div>
@@ -432,12 +596,14 @@ function HomeView({
// ─── ProcessCardWithSim (carga simulación lazy) ───────────────────────────────
function ProcessCardWithSim({ process, showGroupChip, groupName }: {
function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, onShared }: {
process: Process
showGroupChip?: boolean
groupName?: string
currentUserId: string
onShared: () => void
}) {
const { getLatestSimulation } = useLibraryStore()
const { getLatestSimulation, deleteProcess } = useLibraryStore()
const [simulation, setSimulation] = useState<Simulation | null | undefined>(undefined)
useEffect(() => {
@@ -448,7 +614,17 @@ function ProcessCardWithSim({ process, showGroupChip, groupName }: {
return () => { cancelled = true }
}, [process.id, getLatestSimulation])
return <ProcessCard process={process} simulation={simulation} showGroupChip={showGroupChip} groupName={groupName} />
return (
<ProcessCard
process={process}
simulation={simulation}
showGroupChip={showGroupChip}
groupName={groupName}
onDelete={() => deleteProcess(process.id)}
currentUserId={currentUserId}
onShared={onShared}
/>
)
}
// ─── Group View ───────────────────────────────────────────────────────────────
@@ -456,16 +632,27 @@ function ProcessCardWithSim({ process, showGroupChip, groupName }: {
function GroupView({
groupId,
onBack,
currentUserId,
ownerFilter,
onShared,
}: {
groupId: string | null
onBack: () => void
currentUserId: string
ownerFilter: 'all' | 'mine'
onShared: () => void
}) {
const { groups, processes, settings } = useLibraryStore()
const [search, setSearch] = useState('')
const group = groupId ? groups.find((g) => g.id === groupId) : null
const groupName = group?.name ?? `Procesos sin ${settings.groupLabel.toLowerCase()}`
const groupProcesses = processes
const baseProcesses = ownerFilter === 'mine'
? processes.filter((p) => p.ownerId === currentUserId)
: processes
const groupProcesses = baseProcesses
.filter((p) => groupId === null ? p.groupId == null : p.groupId === groupId)
.filter((p) => matchesSearch(p, search))
@@ -512,7 +699,12 @@ function GroupView({
{/* Lista de procesos */}
<div className="space-y-2">
{groupProcesses.map((p) => (
<ProcessCardWithSim key={p.id} process={p} />
<ProcessCardWithSim
key={p.id}
process={p}
currentUserId={currentUserId}
onShared={onShared}
/>
))}
</div>
</div>
@@ -523,14 +715,18 @@ function GroupView({
export function LibraryPage() {
const navigate = useNavigate()
const { load, isLoading, processes, groups, settings } = useLibraryStore()
const { load, processes, groups, settings } = useLibraryStore()
const { user } = useAuth()
const [view, setView] = useState<'home' | 'group'>('home')
const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
const [transitioning, setTransitioning] = useState(false)
const [ownerFilter, setOwnerFilter] = useState<'all' | 'mine'>('all')
const { message: toastMessage, show: showToast } = useToast()
useEffect(() => {
if (!user) return // no fetchear hasta tener usuario confirmado (evita race condition con sesión Supabase)
load()
}, [load])
}, [load, user])
function navigateToGroup(groupId: string | null) {
setTransitioning(true)
@@ -558,7 +754,7 @@ export function LibraryPage() {
}
}
// Preflight fix: contar grupos reales en lugar del clientName heredado
const currentUserId = user!.id
const totalGroups = groups.length
return (
@@ -574,6 +770,30 @@ export function LibraryPage() {
</p>
</div>
<div className="flex items-center gap-2">
{/* Toggle Todos / Míos */}
<div className="flex rounded-lg border border-border overflow-hidden text-[12px]">
<button
className="px-3 py-1.5 font-medium transition-colors"
style={ownerFilter === 'all'
? { background: '#F59845', color: 'white' }
: { background: 'transparent', color: 'hsl(var(--muted-foreground))' }
}
onClick={() => setOwnerFilter('all')}
>
Todos
</button>
<button
className="px-3 py-1.5 font-medium transition-colors border-l border-border"
style={ownerFilter === 'mine'
? { background: '#F59845', color: 'white' }
: { background: 'transparent', color: 'hsl(var(--muted-foreground))' }
}
onClick={() => setOwnerFilter('mine')}
>
Míos
</button>
</div>
<button
onClick={() => navigate({ to: '/settings' })}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
@@ -592,30 +812,32 @@ export function LibraryPage() {
</div>
</div>
{/* Contenido con transición */}
{isLoading ? (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
) : (
<div
className="transition-all duration-150"
style={{ opacity: transitioning ? 0 : 1, transform: transitioning ? 'translateY(6px)' : 'translateY(0)' }}
>
{view === 'home' ? (
<HomeView
onNavigateToGroup={navigateToGroup}
onImport={handleImport}
/>
) : (
<GroupView
groupId={activeGroupId}
onBack={navigateHome}
/>
)}
</div>
)}
{/* Contenido — sin spinner bloqueante: skeleton dentro de HomeView */}
<div
className="transition-all duration-150"
style={{ opacity: transitioning ? 0 : 1, transform: transitioning ? 'translateY(6px)' : 'translateY(0)' }}
>
{view === 'home' ? (
<HomeView
onNavigateToGroup={navigateToGroup}
onImport={handleImport}
ownerFilter={ownerFilter}
currentUserId={currentUserId}
onShared={() => showToast('Proceso compartido con el equipo')}
/>
) : (
<GroupView
groupId={activeGroupId}
onBack={navigateHome}
currentUserId={currentUserId}
ownerFilter={ownerFilter}
onShared={() => showToast('Proceso compartido con el equipo')}
/>
)}
</div>
</div>
<Toast message={toastMessage} />
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUpDown, ArrowUp, ArrowDown, ChevronRight } from 'lucide-react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { activityColor } from '@/lib/colors'
@@ -108,9 +108,8 @@ export function ActivitiesTable({ perActivity, pairedBreakdown, mode, currency,
const hasBreakdown = !(act.resourceCostBreakdown.length === 0 && act.expectedDirectCost === 0)
return (
<>
<Fragment key={act.activityId}>
<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' : ''}`}
@@ -217,7 +216,7 @@ export function ActivitiesTable({ perActivity, pairedBreakdown, mode, currency,
</TableCell>
</TableRow>
)}
</>
</Fragment>
)
})}
</TableBody>

View File

@@ -151,9 +151,12 @@ export const HeatmapCanvas = forwardRef<HeatmapCanvasHandle, HeatmapCanvasProps>
setIsImported(false)
const eventBus = viewer.get('eventBus') as any
let cancelled = false
const onImportDone = () => {
eventBus.off('import.done', onImportDone)
// Si el viewer fue destruido y reemplazado (p.ej. React Strict Mode), ignorar
if (cancelled) return
const canvas = viewer.get('canvas') as any
try { canvas.zoom('fit-viewport', 'auto') } catch { /* canvas puede no estar listo */ }
isImportedRef.current = true
@@ -163,12 +166,14 @@ export const HeatmapCanvas = forwardRef<HeatmapCanvasHandle, HeatmapCanvasProps>
eventBus.on('import.done', onImportDone)
viewer.importXML(xml).catch((err: unknown) => {
if (cancelled) return
// 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 () => {
cancelled = true
eventBus.off('import.done', onImportDone)
}
}, [xml])

View File

@@ -1,6 +1,9 @@
import { useEffect, useState } from 'react'
import { activityRepo, resourceRepo, simulationRepo } from '@/persistence/repositories'
import { supabase } from '@/lib/supabase'
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
import { supabaseResourceRepo } from '@/persistence/supabase/resource-repo'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { supabaseSimulationRepo } from '@/persistence/supabase/simulation-repo'
import type { Process, Activity, Resource, Simulation } from '@/domain/types'
interface ReportData {
@@ -27,11 +30,13 @@ export function useReportData(processId: string): ReportData {
setIsLoading(true)
setError(null)
try {
// Barrera única: garantiza sesión restaurada antes del fan-out en paralelo
await supabase.auth.getSession()
const [proc, sim, acts, res] = await Promise.all([
supabaseProcessRepo.getById(processId),
simulationRepo.getLatestByProcess(processId),
activityRepo.getByProcess(processId),
resourceRepo.getByProcess(processId),
supabaseSimulationRepo.getLatestByProcess(processId),
supabaseActivityRepo.getByProcess(processId),
supabaseResourceRepo.getAll(),
])
if (cancelled) return
if (!proc) throw new Error('Proceso no encontrado en la base de datos local')

View File

@@ -10,6 +10,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Switch } from '@/components/ui/switch'
import { Separator } from '@/components/ui/separator'
import { useProcessStore } from '@/store/process-store'
import { useAuth } from '@/auth/AuthContext'
import { formatCurrency } from '@/lib/format'
import type { Activity } from '@/domain/types'
@@ -19,6 +20,7 @@ interface ActivityPanelProps {
export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
const { activities, resources, updateActivity, currentProcess } = useProcessStore()
const { user } = useAuth()
const [localActivity, setLocalActivity] = useState<Activity | null>(null)
const [isDirty, setIsDirty] = useState(false)
@@ -42,7 +44,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
async function handleSave() {
if (!localActivity) return
await updateActivity(localActivity)
await updateActivity(localActivity, user!.id)
setIsDirty(false)
}
@@ -134,8 +136,11 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
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 className="flex flex-col items-center justify-center h-full gap-2 px-6 text-center">
<p className="text-xs text-slate-400">Actividad no encontrada en la base de datos.</p>
<p className="text-[11px] text-slate-300">
Si importaste este proceso antes de la última actualización, volvé a importar el archivo BPMN.
</p>
</div>
)
}

View File

@@ -110,12 +110,17 @@ export function BpmnCanvas({
if (!viewer || !xml) return
isImportedRef.current = false
viewer.importXML(xml).then(({ warnings }) => {
// Si el viewer fue destruido y reemplazado (p.ej. React Strict Mode), ignorar
if (viewerRef.current !== viewer) return
if (warnings.length) console.warn('bpmn-js warnings:', warnings)
const canvas = viewer.get('canvas') as any
canvas.zoom('fit-viewport', 'auto')
isImportedRef.current = true
syncAutomationOverlays()
}).catch((err: Error) => console.error('Error cargando BPMN:', err))
}).catch((err: Error) => {
if (viewerRef.current !== viewer) return
console.error('Error cargando BPMN:', err)
})
}, [xml, syncAutomationOverlays])
// ─── Re-sincronizar overlays cuando cambia la lista de automatizables ─────

View File

@@ -6,6 +6,7 @@ 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 { useAuth } from '@/auth/AuthContext'
import { parseBpmnXml } from '@/domain/bpmn-parser'
import { formatPercent } from '@/lib/format'
import type { GatewayConfig } from '@/domain/types'
@@ -28,6 +29,7 @@ export function isXorSumValid(gw: GatewayConfig): boolean {
export function GatewayPanel({ selectedElementId }: GatewayPanelProps) {
const { gateways, currentProcess, updateGateway, activities } = useProcessStore()
const { user } = useAuth()
const [localGw, setLocalGw] = useState<GatewayConfig | null>(null)
const [isDirty, setIsDirty] = useState(false)
@@ -82,7 +84,7 @@ export function GatewayPanel({ selectedElementId }: GatewayPanelProps) {
async function handleSave() {
if (!localGw) return
await updateGateway(localGw)
await updateGateway(localGw, user!.id)
setIsDirty(false)
}

View File

@@ -9,6 +9,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useProcessStore } from '@/store/process-store'
import { useAuth } from '@/auth/AuthContext'
import { formatPercent } from '@/lib/format'
const CURRENCIES = [
@@ -24,6 +25,7 @@ const CURRENCIES = [
export function GlobalSettingsPanel() {
const { currentProcess, updateProcess } = useProcessStore()
const { user } = useAuth()
const [localName, setLocalName] = useState('')
const [localClient, setLocalClient] = useState('')
const [localCurrency, setLocalCurrency] = useState('USD')
@@ -57,7 +59,7 @@ export function GlobalSettingsPanel() {
annualFrequency: Math.max(1, localFrequency),
analysisHorizonYears: clampedHorizon,
automationInvestment: Math.max(0, localInvestment),
})
}, user!.id)
setLocalHorizon(clampedHorizon)
setIsDirty(false)
}

View File

@@ -7,6 +7,7 @@ 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 { useAuth } from '@/auth/AuthContext'
import { formatCurrency } from '@/lib/format'
import type { Resource, ResourceType } from '@/domain/types'
@@ -36,6 +37,7 @@ const EMPTY_FORM: ResourceFormState = { name: '', type: 'role', costPerHour: ''
export function ResourcesPanel() {
const { currentProcess, resources, addResource, updateResource, deleteResource } = useProcessStore()
const { user } = useAuth()
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState<ResourceFormState>(EMPTY_FORM)
@@ -65,16 +67,15 @@ export function ResourcesPanel() {
if (editingId) {
const existing = resources.find((r) => r.id === editingId)
if (!existing) return
await updateResource({ ...existing, name: form.name.trim(), type: form.type, costPerHour })
await updateResource({ ...existing, name: form.name.trim(), type: form.type, costPerHour }, user!.id)
} else {
const newResource: Resource = {
id: uuidv4(),
processId: currentProcess.id,
name: form.name.trim(),
type: form.type,
costPerHour,
}
await addResource(newResource)
await addResource(newResource, user!.id)
}
cancelForm()
}
@@ -186,7 +187,7 @@ export function ResourcesPanel() {
variant="ghost"
size="icon"
className="h-6 w-6 text-slate-400 hover:text-destructive"
onClick={() => deleteResource(resource.id)}
onClick={() => deleteResource(resource.id, user!.id)}
>
<Trash2 className="h-3 w-3" />
</Button>

View File

@@ -19,6 +19,7 @@ import { useProcessStore } from '@/store/process-store'
import { useSimulationStore } from '@/store/simulation-store'
import { useSimulate } from '../simulation/useSimulate'
import { useLibraryStore } from '@/store/library-store'
import { useAuth } from '@/auth/AuthContext'
import { AppHeader } from '@/components/AppHeader'
type SelectedCategory = 'activity' | 'gateway' | null
@@ -30,6 +31,7 @@ export function WorkspacePage() {
const { isRunning, resultActual, error: simError, selectActivity, loadLatestForProcess, lastPersistedExecutedAt } = useSimulationStore()
const { simulate } = useSimulate()
const { toast } = useToast()
const { user } = useAuth()
const [sidebarOpen, setSidebarOpen] = useState(true)
const [selectedElementId, setSelectedElementId] = useState<string | null>(null)
@@ -122,12 +124,12 @@ export function WorkspacePage() {
if (field === 'name') {
const trimmed = editValue.trim()
if (trimmed && trimmed !== currentProcess?.name) {
await updateProcess({ name: trimmed })
await updateProcess({ name: trimmed }, user!.id)
}
} else {
const trimmed = editValue.trim()
if (trimmed !== (currentProcess?.clientName ?? '')) {
await updateProcess({ clientName: trimmed })
await updateProcess({ clientName: trimmed }, user!.id)
}
}
setEditingField(null)
@@ -141,14 +143,14 @@ export function WorkspacePage() {
if (!normalized) return
const current = currentProcess?.tags ?? []
if (current.some((t) => t === normalized)) return
updateProcess({ tags: [...current, normalized] })
updateProcess({ tags: [...current, normalized] }, user!.id)
setTagInput('')
setShowTagSuggestions(false)
}
function removeTag(tag: string) {
const current = currentProcess?.tags ?? []
updateProcess({ tags: current.filter((t) => t !== tag) })
updateProcess({ tags: current.filter((t) => t !== tag) }, user!.id)
}
function handleTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {

View File

@@ -1,125 +0,0 @@
import Dexie, { type Table } from 'dexie'
import type { Process, Activity, GatewayConfig, Resource, Simulation, ProcessGroup, AppSettings } from '@/domain/types'
export class ProcessCostDb extends Dexie {
processes!: Table<Process>
activities!: Table<Activity>
gateways!: Table<GatewayConfig>
resources!: Table<Resource>
simulations!: Table<Simulation>
groups!: Table<ProcessGroup> // 🆕 Sprint 3
settings!: Table<AppSettings> // 🆕 Sprint 3
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',
})
// Sprint 1: agrega campos de automatización a Activity y volumetría a Process.
// El upgrade popula datos existentes con defaults para garantizar invariante de tipo.
this.version(2)
.stores({
processes: 'id, name, updatedAt',
activities: 'id, processId, bpmnElementId',
gateways: 'id, processId, bpmnElementId',
resources: 'id, processId, name',
simulations: 'id, processId, executedAt',
})
.upgrade(async (tx) => {
await tx.table('activities').toCollection().modify((act) => {
if (act.automatable === undefined) act.automatable = false
if (act.automatedCostFixed === undefined) act.automatedCostFixed = 0
if (act.automatedTimeMinutes === undefined) act.automatedTimeMinutes = 0
})
await tx.table('processes').toCollection().modify((proc) => {
if (proc.annualFrequency === undefined) proc.annualFrequency = 1000
if (proc.analysisHorizonYears === undefined) proc.analysisHorizonYears = 3
if (proc.automationInvestment === undefined) proc.automationInvestment = 0
})
})
// Sprint 2: migra ActivityResourceAssignment.utilizationPercent → utilizationMinutes + units.
// Los stores no cambian — assignments están embebidos en Activity, no son tabla propia.
this.version(3)
.stores({
processes: 'id, name, updatedAt',
activities: 'id, processId, bpmnElementId',
gateways: 'id, processId, bpmnElementId',
resources: 'id, processId, name',
simulations: 'id, processId, executedAt',
})
.upgrade(async (tx) => {
await tx.table('activities').toCollection().modify((act) => {
if (!Array.isArray(act.assignedResources)) {
act.assignedResources = []
return
}
act.assignedResources = act.assignedResources.map((assignment: Record<string, unknown>) => {
if ('utilizationPercent' in assignment) {
// Heurística: minutos = round(executionTimeMinutes × utilizationPercent)
// Si executionTimeMinutes no está disponible o es 0, usar 0 (conservador)
const execMinutes = typeof act.executionTimeMinutes === 'number' ? act.executionTimeMinutes : 0
const utilizationMinutes = Math.round(execMinutes * (assignment.utilizationPercent as number))
return { resourceId: assignment.resourceId, utilizationMinutes, units: 1 }
}
if ('utilizationMinutes' in assignment) {
// Ya está en nuevo formato (idempotencia)
return {
resourceId: assignment.resourceId,
utilizationMinutes: assignment.utilizationMinutes ?? 0,
units: assignment.units ?? 1,
}
}
// Fallback defensivo
return { resourceId: assignment.resourceId, utilizationMinutes: 0, units: 1 }
})
})
})
// Sprint 3: agrega tabla de grupos (Process Library), tabla de settings,
// campos groupId/tags en Process, y automatedAssignedResources en Activity.
this.version(4)
.stores({
processes: 'id, name, updatedAt, groupId', // groupId indexado para queries por grupo
activities: 'id, processId, bpmnElementId',
gateways: 'id, processId, bpmnElementId',
resources: 'id, processId, name',
simulations: 'id, processId, executedAt',
groups: 'id, name, updatedAt', // nueva tabla
settings: 'id', // nueva tabla (singleton)
})
.upgrade(async (tx) => {
// Process: agregar groupId (null) y tags ([])
await tx.table('processes').toCollection().modify((proc) => {
if (proc.groupId === undefined) proc.groupId = null
if (!Array.isArray(proc.tags)) proc.tags = []
})
// Activity: agregar automatedAssignedResources ([])
await tx.table('activities').toCollection().modify((act) => {
if (!Array.isArray(act.automatedAssignedResources)) {
act.automatedAssignedResources = []
}
})
// Settings: crear singleton con defaults (put es idempotente por clave primaria)
await tx.table('settings').put({ id: 'singleton', groupLabel: 'Cliente' })
})
// Belt-and-suspenders: garantizar singleton en fresh installs y entornos de test.
// El upgrade también lo crea; este handler cubre el caso en que la tabla sea nueva
// pero el upgrade no haya persistido el put (conocido con fake-indexeddb).
this.on('ready', async () => {
const existing = await this.settings.get('singleton')
if (!existing) {
await this.settings.put({ id: 'singleton', groupLabel: 'Cliente' })
}
})
}
}
export const db = new ProcessCostDb()

View File

@@ -1,180 +0,0 @@
/**
* @internal
*
* Repositorios de acceso a IndexedDB (Dexie).
*
* REGLA ARQUITECTÓNICA: este módulo solo debe importarse desde `src/store/**`.
* Las features y hooks deben mutar datos a través de los stores (useProcessStore,
* useSimulationStore), que garantizan la invalidación del resultado de simulación
* cuando los datos cambian.
*
* Excepciones permitidas (ver eslint.config.js):
* - src/features/import/** → operación de importación inicial (no hay simulación previa)
* - src/features/report/useReportData.ts → solo lectura, sin mutaciones
* - tests/** → acceso directo controlado en contexto de testing
*
* Cualquier import desde fuera de estos paths falla `npm run lint`.
*/
import { db } from './db'
import type { Process, Activity, GatewayConfig, Resource, Simulation, ProcessGroup, AppSettings } from '@/domain/types'
// ─── Process ─────────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/process-store.ts */
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 ─────────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/process-store.ts */
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 ────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/process-store.ts */
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 ─────────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/process-store.ts */
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 ───────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/simulation-store.ts */
export const simulationRepo = {
async save(simulation: Simulation): Promise<void> {
await db.simulations.put(simulation)
},
async getLatestByProcess(processId: string): Promise<Simulation | undefined> {
// Ordena por executedAt descendente para retornar la simulación más reciente.
// No se usa .last() porque el primary key es UUID (no ordenado cronológicamente).
const all = await db.simulations
.where('processId')
.equals(processId)
.toArray()
if (!all.length) return undefined
return all.reduce((latest, sim) => sim.executedAt > latest.executedAt ? sim : latest)
},
async getByProcess(processId: string): Promise<Simulation[]> {
return db.simulations.where('processId').equals(processId).toArray()
},
}
// ─── ProcessGroup ─────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/library-store.ts */
export const groupRepo = {
async save(group: ProcessGroup): Promise<void> {
await db.groups.put(group)
},
async getAll(): Promise<ProcessGroup[]> {
return db.groups.orderBy('name').toArray()
},
async getById(id: string): Promise<ProcessGroup | undefined> {
return db.groups.get(id)
},
async delete(id: string): Promise<void> {
// Procesos del grupo pasan a "Sin clasificar" (groupId = null)
await db.transaction('rw', [db.groups, db.processes], async () => {
await db.processes.where('groupId').equals(id).modify({ groupId: null })
await db.groups.delete(id)
})
},
}
// ─── AppSettings ──────────────────────────────────────────────────────────────
/** @internal Solo llamar desde src/store/library-store.ts */
export const settingsRepo = {
async get(): Promise<AppSettings> {
const s = await db.settings.get('singleton')
return s ?? { id: 'singleton', groupLabel: 'Cliente' }
},
async update(updates: Partial<Omit<AppSettings, 'id'>>): Promise<void> {
await db.settings.update('singleton', updates)
},
}

View File

@@ -0,0 +1,183 @@
import { supabase } from '@/lib/supabase'
import type { Activity, ActivityResourceAssignment } from '@/domain/types'
function fromRow(
row: Record<string, unknown>,
assignedResources: ActivityResourceAssignment[],
automatedAssignedResources: ActivityResourceAssignment[],
): Activity {
return {
id: row.id as string,
processId: row.process_id as string,
bpmnElementId: row.bpmn_element_id as string,
name: (row.name as string) ?? '',
type: (row.type as Activity['type']) ?? 'task',
directCostFixed: Number(row.direct_cost_fixed ?? 0),
executionTimeMinutes: Number(row.execution_time_minutes ?? 0),
automatable: Boolean(row.automatable),
automatedCostFixed: Number(row.automated_cost_fixed ?? 0),
automatedTimeMinutes: Number(row.automated_time_minutes ?? 0),
assignedResources,
automatedAssignedResources,
}
}
function assignmentFromRow(row: Record<string, unknown>): ActivityResourceAssignment {
return {
resourceId: row.resource_id as string,
utilizationMinutes: Number(row.utilization_minutes ?? 0),
units: Number(row.units ?? 1),
}
}
// Extrae assignments de la relación anidada que Supabase devuelve con select('*, activity_resource_assignments(*)')
function extractAssignments(row: Record<string, unknown>): {
actual: ActivityResourceAssignment[]
automated: ActivityResourceAssignment[]
} {
const raw = (row.activity_resource_assignments as Array<Record<string, unknown>>) ?? []
return {
actual: raw.filter((a) => a.scenario === 'actual').map(assignmentFromRow),
automated: raw.filter((a) => a.scenario === 'automated').map(assignmentFromRow),
}
}
export const supabaseActivityRepo = {
async save(activity: Activity, userId: string): Promise<void> {
const { error: actError } = await supabase.from('activities').upsert({
id: activity.id,
process_id: activity.processId,
bpmn_element_id: activity.bpmnElementId,
name: activity.name,
type: activity.type,
direct_cost_fixed: activity.directCostFixed,
execution_time_minutes: activity.executionTimeMinutes,
automatable: activity.automatable,
automated_cost_fixed: activity.automatedCostFixed,
automated_time_minutes: activity.automatedTimeMinutes,
updated_by: userId,
updated_at: new Date().toISOString(),
})
if (actError) throw actError
const { error: delError } = await supabase
.from('activity_resource_assignments')
.delete()
.eq('activity_id', activity.id)
if (delError) throw delError
const assignments = [
...activity.assignedResources.map((r) => ({
activity_id: activity.id,
resource_id: r.resourceId,
utilization_minutes: r.utilizationMinutes,
units: r.units,
scenario: 'actual',
})),
...(activity.automatedAssignedResources ?? []).map((r) => ({
activity_id: activity.id,
resource_id: r.resourceId,
utilization_minutes: r.utilizationMinutes,
units: r.units,
scenario: 'automated',
})),
]
if (assignments.length > 0) {
const { error: insError } = await supabase
.from('activity_resource_assignments')
.insert(assignments)
if (insError) throw insError
}
},
// Bulk insert optimizado: 3 round-trips totales sin importar el número de actividades
async saveMany(activities: Activity[], userId: string): Promise<void> {
if (activities.length === 0) return
const now = new Date().toISOString()
const { error: actError } = await supabase.from('activities').upsert(
activities.map((a) => ({
id: a.id,
process_id: a.processId,
bpmn_element_id: a.bpmnElementId,
name: a.name,
type: a.type,
direct_cost_fixed: a.directCostFixed,
execution_time_minutes: a.executionTimeMinutes,
automatable: a.automatable,
automated_cost_fixed: a.automatedCostFixed,
automated_time_minutes: a.automatedTimeMinutes,
updated_by: userId,
updated_at: now,
}))
)
if (actError) throw actError
const activityIds = activities.map((a) => a.id)
const { error: delError } = await supabase
.from('activity_resource_assignments')
.delete()
.in('activity_id', activityIds)
if (delError) throw delError
const assignments = activities.flatMap((a) => [
...a.assignedResources.map((r) => ({
activity_id: a.id,
resource_id: r.resourceId,
utilization_minutes: r.utilizationMinutes,
units: r.units,
scenario: 'actual',
})),
...(a.automatedAssignedResources ?? []).map((r) => ({
activity_id: a.id,
resource_id: r.resourceId,
utilization_minutes: r.utilizationMinutes,
units: r.units,
scenario: 'automated',
})),
])
if (assignments.length > 0) {
const { error: insError } = await supabase
.from('activity_resource_assignments')
.insert(assignments)
if (insError) throw insError
}
},
// Un solo round-trip: activities + assignments anidados en una query
async getByProcess(processId: string): Promise<Activity[]> {
const { data, error } = await supabase
.from('activities')
.select('*, activity_resource_assignments(*)')
.eq('process_id', processId)
if (error) throw error
return (data ?? []).map((row) => {
const { actual, automated } = extractAssignments(row as Record<string, unknown>)
return fromRow(row as Record<string, unknown>, actual, automated)
})
},
// Un solo round-trip: actividad + assignments anidados
async getByBpmnElementId(processId: string, bpmnElementId: string): Promise<Activity | undefined> {
const { data, error } = await supabase
.from('activities')
.select('*, activity_resource_assignments(*)')
.eq('process_id', processId)
.eq('bpmn_element_id', bpmnElementId)
.single()
if (error) return undefined
const { actual, automated } = extractAssignments(data as Record<string, unknown>)
return fromRow(data as Record<string, unknown>, actual, automated)
},
async deleteByProcess(processId: string): Promise<void> {
// CASCADE elimina activity_resource_assignments automáticamente
const { error } = await supabase.from('activities').delete().eq('process_id', processId)
if (error) throw error
},
}

View File

@@ -0,0 +1,55 @@
import { supabase } from '@/lib/supabase'
import type { GatewayConfig } from '@/domain/types'
function fromRow(row: Record<string, unknown>): GatewayConfig {
return {
id: row.id as string,
processId: row.process_id as string,
bpmnElementId: row.bpmn_element_id as string,
gatewayType: row.gateway_type as GatewayConfig['gatewayType'],
branches: Array.isArray(row.branches) ? row.branches as GatewayConfig['branches'] : [],
}
}
export const supabaseGatewayRepo = {
async save(gateway: GatewayConfig): Promise<void> {
const { error } = await supabase.from('gateways').upsert({
id: gateway.id,
process_id: gateway.processId,
bpmn_element_id: gateway.bpmnElementId,
gateway_type: gateway.gatewayType,
branches: gateway.branches,
updated_at: new Date().toISOString(),
})
if (error) throw error
},
async saveMany(gateways: GatewayConfig[]): Promise<void> {
if (gateways.length === 0) return
const { error } = await supabase.from('gateways').upsert(
gateways.map((g) => ({
id: g.id,
process_id: g.processId,
bpmn_element_id: g.bpmnElementId,
gateway_type: g.gatewayType,
branches: g.branches,
updated_at: new Date().toISOString(),
}))
)
if (error) throw error
},
async getByProcess(processId: string): Promise<GatewayConfig[]> {
const { data, error } = await supabase
.from('gateways')
.select('*')
.eq('process_id', processId)
if (error) throw error
return (data ?? []).map(fromRow)
},
async deleteByProcess(processId: string): Promise<void> {
const { error } = await supabase.from('gateways').delete().eq('process_id', processId)
if (error) throw error
},
}

View File

@@ -1,12 +1,6 @@
import { supabase } from '@/lib/supabase'
import type { ProcessGroup } from '@/domain/types'
async function getCurrentUserId(): Promise<string> {
const { data: { session } } = await supabase.auth.getSession()
if (!session?.user) throw new Error('Usuario no autenticado')
return session.user.id
}
function fromRow(row: Record<string, unknown>): ProcessGroup {
return {
id: row.id as string,
@@ -17,8 +11,7 @@ function fromRow(row: Record<string, unknown>): ProcessGroup {
}
export const supabaseGroupRepo = {
async save(group: ProcessGroup): Promise<void> {
const userId = await getCurrentUserId()
async save(group: ProcessGroup, userId: string): Promise<void> {
const { error } = await supabase.from('process_groups').upsert({
id: group.id,
name: group.name,

View File

@@ -1,13 +1,6 @@
import { supabase } from '@/lib/supabase'
import { db } from '@/persistence/db'
import type { Process } from '@/domain/types'
async function getCurrentUserId(): Promise<string> {
const { data: { session } } = await supabase.auth.getSession()
if (!session?.user) throw new Error('Usuario no autenticado')
return session.user.id
}
function toRow(process: Process, userId: string) {
return {
id: process.id,
@@ -21,7 +14,8 @@ function toRow(process: Process, userId: string) {
automation_investment: process.automationInvestment,
group_id: process.groupId,
tags: process.tags,
owner_id: userId,
// owner_id se preserva del valor existente en el proceso; solo se inicializa con userId en creates
owner_id: process.ownerId || userId,
created_by: userId,
updated_by: userId,
updated_at: new Date().toISOString(),
@@ -43,12 +37,12 @@ function fromRow(row: Record<string, unknown>): Process {
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
createdAt: new Date(row.created_at as string).getTime(),
updatedAt: new Date(row.updated_at as string).getTime(),
ownerId: (row.owner_id as string) ?? '',
}
}
export const supabaseProcessRepo = {
async save(process: Process): Promise<void> {
const userId = await getCurrentUserId()
async save(process: Process, userId: string): Promise<void> {
const { error } = await supabase.from('processes').upsert(toRow(process, userId))
if (error) throw error
},
@@ -73,15 +67,14 @@ export const supabaseProcessRepo = {
},
async delete(id: string): Promise<void> {
// CASCADE en Supabase elimina: activities, gateways, activity_resource_assignments, simulations
const { error } = await supabase.from('processes').delete().eq('id', id)
if (error) throw error
},
// TODO Sprint 4 Etapa 3-4: eliminar limpieza Dexie cuando actividades migren a Supabase
await db.transaction('rw', [db.activities, db.gateways, db.resources, db.simulations], async () => {
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()
})
// No-op en Sprint 4: la implementación real (insertar en process_access) llega cuando existan guests.
// El método existe para que la UI de "Compartir con equipo" funcione sin escritura a DB.
async setShared(_processId: string, _shared: boolean): Promise<void> {
return
},
}

View File

@@ -0,0 +1,40 @@
import { supabase } from '@/lib/supabase'
import type { Resource } from '@/domain/types'
function fromRow(row: Record<string, unknown>): Resource {
return {
id: row.id as string,
name: row.name as string,
type: row.type as Resource['type'],
costPerHour: Number(row.cost_per_hour ?? 0),
}
}
export const supabaseResourceRepo = {
async save(resource: Resource, userId: string): Promise<void> {
const { error } = await supabase.from('resources').upsert({
id: resource.id,
name: resource.name,
type: resource.type,
cost_per_hour: resource.costPerHour,
created_by: userId,
updated_by: userId,
updated_at: new Date().toISOString(),
})
if (error) throw error
},
async getAll(): Promise<Resource[]> {
const { data, error } = await supabase
.from('resources')
.select('*')
.order('name', { ascending: true })
if (error) throw error
return (data ?? []).map(fromRow)
},
async delete(resourceId: string): Promise<void> {
const { error } = await supabase.from('resources').delete().eq('id', resourceId)
if (error) throw error
},
}

View File

@@ -5,12 +5,21 @@ const DEFAULT_SETTINGS: AppSettings = { id: 'singleton', groupLabel: 'Cliente' }
export const supabaseSettingsRepo = {
async get(): Promise<AppSettings> {
const { data } = await supabase
const { data, error } = await supabase
.from('app_settings')
.select('id, group_label')
.eq('id', 'singleton')
.single()
if (!data) return DEFAULT_SETTINGS
// PGRST116 = 0 rows — singleton aún no existe, crearlo y retornar el default
if (error?.code === 'PGRST116' || !data) {
await supabase.from('app_settings').upsert({
id: 'singleton',
group_label: 'Cliente',
})
return DEFAULT_SETTINGS
}
return { id: 'singleton', groupLabel: (data.group_label as string) ?? 'Cliente' }
},

View File

@@ -0,0 +1,49 @@
import { supabase } from '@/lib/supabase'
import type { Simulation, SimulationResult } from '@/domain/types'
function fromRow(row: Record<string, unknown>): Simulation {
return {
id: row.id as string,
processId: row.process_id as string,
executedAt: Number(row.executed_at),
result: row.result as SimulationResult,
resultAutomated: row.result_automated != null
? (row.result_automated as SimulationResult)
: undefined,
}
}
export const supabaseSimulationRepo = {
async save(simulation: Simulation): Promise<void> {
const { error } = await supabase.from('simulations').insert({
id: simulation.id,
process_id: simulation.processId,
executed_at: simulation.executedAt,
result: simulation.result,
result_automated: simulation.resultAutomated ?? null,
})
if (error) throw error
},
async getLatestByProcess(processId: string): Promise<Simulation | undefined> {
const { data, error } = await supabase
.from('simulations')
.select('*')
.eq('process_id', processId)
.order('executed_at', { ascending: false })
.limit(1)
.single()
if (error) return undefined
return fromRow(data as Record<string, unknown>)
},
async getByProcess(processId: string): Promise<Simulation[]> {
const { data, error } = await supabase
.from('simulations')
.select('*')
.eq('process_id', processId)
.order('executed_at', { ascending: false })
if (error) throw error
return (data ?? []).map(fromRow)
},
}

View File

@@ -47,7 +47,11 @@ function RootComponent() {
)
}
if (!user && pathname !== '/login') return null
if (!user && pathname !== '/login') return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} />
</div>
)
return <Outlet />
}

View File

@@ -1,9 +1,10 @@
import { create } from 'zustand'
import { v4 as uuidv4 } from 'uuid'
import { supabase } from '@/lib/supabase'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { supabaseGroupRepo } from '@/persistence/supabase/group-repo'
import { supabaseSettingsRepo } from '@/persistence/supabase/settings-repo'
import { simulationRepo } from '@/persistence/repositories'
import { supabaseSimulationRepo } from '@/persistence/supabase/simulation-repo'
import type { Process, ProcessGroup, AppSettings, Simulation } from '@/domain/types'
interface LibraryState {
@@ -14,12 +15,12 @@ interface LibraryState {
load: () => Promise<void>
createGroup: (name: string) => Promise<ProcessGroup>
createGroup: (name: string, userId: string) => Promise<ProcessGroup>
deleteGroup: (id: string) => Promise<void>
deleteProcess: (id: string) => Promise<void>
assignProcessToGroup: (processId: string, groupId: string | null) => Promise<void>
assignProcessToGroup: (processId: string, groupId: string | null, userId: string) => Promise<void>
getProcessesByGroup: (groupId: string | null) => Process[]
getLatestSimulation: (processId: string) => Promise<Simulation | undefined>
@@ -39,6 +40,14 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
load: async () => {
set({ isLoading: true })
try {
// Barrera única: garantiza que el cliente Supabase tenga la sesión restaurada
// antes de salir a buscar datos en paralelo (evita 0 filas por RLS en el primer load)
const { data: sessionData } = await supabase.auth.getSession()
console.log('[library-store] load session:', {
hasSession: !!sessionData.session,
userId: sessionData.session?.user?.id ?? 'NULL',
})
const [groups, processes, settings] = await Promise.all([
supabaseGroupRepo.getAll(),
supabaseProcessRepo.getAll(),
@@ -51,15 +60,15 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
}
},
createGroup: async (name: string) => {
createGroup: async (name, userId) => {
const now = Date.now()
const group: ProcessGroup = { id: uuidv4(), name, createdAt: now, updatedAt: now }
await supabaseGroupRepo.save(group)
await supabaseGroupRepo.save(group, userId)
set((state) => ({ groups: [...state.groups, group].sort((a, b) => a.name.localeCompare(b.name)) }))
return group
},
deleteGroup: async (id: string) => {
deleteGroup: async (id) => {
await supabaseGroupRepo.delete(id)
set((state) => ({
groups: state.groups.filter((g) => g.id !== id),
@@ -67,30 +76,30 @@ export const useLibraryStore = create<LibraryState>((set, get) => ({
}))
},
deleteProcess: async (id: string) => {
deleteProcess: async (id) => {
await supabaseProcessRepo.delete(id)
set((state) => ({ processes: state.processes.filter((p) => p.id !== id) }))
},
assignProcessToGroup: async (processId: string, groupId: string | null) => {
assignProcessToGroup: async (processId, groupId, userId) => {
const process = get().processes.find((p) => p.id === processId)
if (!process) return
const updated = { ...process, groupId, updatedAt: Date.now() }
await supabaseProcessRepo.save(updated)
await supabaseProcessRepo.save(updated, userId)
set((state) => ({
processes: state.processes.map((p) => p.id === processId ? updated : p),
}))
},
getProcessesByGroup: (groupId: string | null) => {
getProcessesByGroup: (groupId) => {
const processes = get().processes
return groupId === null
? processes.filter((p) => p.groupId == null)
: processes.filter((p) => p.groupId === groupId)
},
getLatestSimulation: (processId: string) => {
return simulationRepo.getLatestByProcess(processId)
getLatestSimulation: (processId) => {
return supabaseSimulationRepo.getLatestByProcess(processId)
},
updateSettings: async (updates) => {

View File

@@ -1,6 +1,9 @@
import { create } from 'zustand'
import { supabase } from '@/lib/supabase'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { activityRepo, gatewayRepo, resourceRepo } from '@/persistence/repositories'
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
import { supabaseGatewayRepo } from '@/persistence/supabase/gateway-repo'
import { supabaseResourceRepo } from '@/persistence/supabase/resource-repo'
import type { Process, Activity, GatewayConfig, Resource } from '@/domain/types'
interface ProcessState {
@@ -12,13 +15,13 @@ interface ProcessState {
error: string | null
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>
setCurrentProcess: (process: Process, userId: string) => Promise<void>
updateProcess: (updates: Partial<Process>, userId: string) => Promise<void>
updateActivity: (activity: Activity, userId: string) => Promise<void>
updateGateway: (gateway: GatewayConfig, userId: string) => Promise<void>
addResource: (resource: Resource, userId: string) => Promise<void>
updateResource: (resource: Resource, userId: string) => Promise<void>
deleteResource: (resourceId: string, userId: string) => Promise<void>
reset: () => void
}
@@ -33,11 +36,13 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
loadProcess: async (processId) => {
set({ isLoading: true, error: null })
try {
// Barrera única: garantiza sesión restaurada antes del fan-out en paralelo
await supabase.auth.getSession()
const [process, activities, gateways, resources] = await Promise.all([
supabaseProcessRepo.getById(processId),
activityRepo.getByProcess(processId),
gatewayRepo.getByProcess(processId),
resourceRepo.getByProcess(processId),
supabaseActivityRepo.getByProcess(processId),
supabaseGatewayRepo.getByProcess(processId),
supabaseResourceRepo.getAll(),
])
if (!process) throw new Error('Proceso no encontrado')
set({ currentProcess: process, activities, gateways, resources, isLoading: false })
@@ -46,30 +51,31 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
}
},
setCurrentProcess: async (process) => {
await supabaseProcessRepo.save(process)
setCurrentProcess: async (process, userId) => {
await supabaseProcessRepo.save(process, userId)
await supabase.auth.getSession()
const [activities, gateways, resources] = await Promise.all([
activityRepo.getByProcess(process.id),
gatewayRepo.getByProcess(process.id),
resourceRepo.getByProcess(process.id),
supabaseActivityRepo.getByProcess(process.id),
supabaseGatewayRepo.getByProcess(process.id),
supabaseResourceRepo.getAll(),
])
set({ currentProcess: process, activities, gateways, resources })
},
updateProcess: async (updates) => {
updateProcess: async (updates, userId) => {
const current = get().currentProcess
if (!current) return
const updated: Process = { ...current, ...updates, updatedAt: Date.now() }
await supabaseProcessRepo.save(updated)
await supabaseProcessRepo.save(updated, userId)
set({ currentProcess: updated })
},
updateActivity: async (activity) => {
await activityRepo.save(activity)
updateActivity: async (activity, userId) => {
await supabaseActivityRepo.save(activity, userId)
const current = get().currentProcess
if (current) {
const updatedProcess = { ...current, updatedAt: Date.now() }
await supabaseProcessRepo.save(updatedProcess)
await supabaseProcessRepo.save(updatedProcess, userId)
set((state) => ({
activities: state.activities.map((a) => (a.id === activity.id ? activity : a)),
currentProcess: updatedProcess,
@@ -81,12 +87,12 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
}
},
updateGateway: async (gateway) => {
await gatewayRepo.save(gateway)
updateGateway: async (gateway, userId) => {
await supabaseGatewayRepo.save(gateway)
const current = get().currentProcess
if (current) {
const updatedProcess = { ...current, updatedAt: Date.now() }
await supabaseProcessRepo.save(updatedProcess)
await supabaseProcessRepo.save(updatedProcess, userId)
set((state) => ({
gateways: state.gateways.map((g) => (g.id === gateway.id ? gateway : g)),
currentProcess: updatedProcess,
@@ -98,24 +104,24 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
}
},
addResource: async (resource) => {
await resourceRepo.save(resource)
addResource: async (resource, userId) => {
await supabaseResourceRepo.save(resource, userId)
const current = get().currentProcess
if (current) {
const updatedProcess = { ...current, updatedAt: Date.now() }
await supabaseProcessRepo.save(updatedProcess)
await supabaseProcessRepo.save(updatedProcess, userId)
set((state) => ({ resources: [...state.resources, resource], currentProcess: updatedProcess }))
} else {
set((state) => ({ resources: [...state.resources, resource] }))
}
},
updateResource: async (resource) => {
await resourceRepo.save(resource)
updateResource: async (resource, userId) => {
await supabaseResourceRepo.save(resource, userId)
const current = get().currentProcess
if (current) {
const updatedProcess = { ...current, updatedAt: Date.now() }
await supabaseProcessRepo.save(updatedProcess)
await supabaseProcessRepo.save(updatedProcess, userId)
set((state) => ({
resources: state.resources.map((r) => (r.id === resource.id ? resource : r)),
currentProcess: updatedProcess,
@@ -127,12 +133,12 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
}
},
deleteResource: async (resourceId) => {
await resourceRepo.delete(resourceId)
deleteResource: async (resourceId, userId) => {
await supabaseResourceRepo.delete(resourceId)
const current = get().currentProcess
if (current) {
const updatedProcess = { ...current, updatedAt: Date.now() }
await supabaseProcessRepo.save(updatedProcess)
await supabaseProcessRepo.save(updatedProcess, userId)
set((state) => ({
resources: state.resources.filter((r) => r.id !== resourceId),
currentProcess: updatedProcess,

View File

@@ -1,28 +1,25 @@
import { create } from 'zustand'
import { v4 as uuidv4 } from 'uuid'
import type { SimulationResult } from '@/domain/types'
import { simulationRepo } from '@/persistence/repositories'
import { supabaseSimulationRepo } from '@/persistence/supabase/simulation-repo'
// Excepción arquitectónica documentada: simulation-store importa process-store en una dirección
// para suscribirse a cambios de datos y invalidar resultados. No existe dependencia inversa.
import { useProcessStore } from '@/store/process-store'
interface SimulationState {
resultActual: SimulationResult | null // resultado del escenario actual
resultAutomated: SimulationResult | null // resultado del escenario automatizado
lastSimulatedAt: Date | null // timestamp de la última simulación exitosa
lastPersistedExecutedAt: number | null // executedAt de la última sim guardada en IndexedDB (sobrevive recargas)
resultActual: SimulationResult | null
resultAutomated: SimulationResult | null
lastSimulatedAt: Date | null
lastPersistedExecutedAt: number | null
isRunning: boolean
error: string | null
selectedActivityId: string | null
// Persiste ambos resultados en IndexedDB y actualiza el estado en memoria.
// REGLA: los dos resultados siempre se guardan y actualizan en conjunto.
persistResults: (processId: string, actual: SimulationResult, automated: SimulationResult) => Promise<void>
setRunning: (running: boolean) => void
setError: (error: string | null) => void
selectActivity: (bpmnElementId: string | null) => void
// Invalida AMBOS resultados a null. Se llama cuando cambian datos del proceso.
invalidateResults: () => void
reset: () => void
loadLatestForProcess: (processId: string) => Promise<void>
@@ -39,7 +36,7 @@ export const useSimulationStore = create<SimulationState>((set) => ({
persistResults: async (processId, actual, automated) => {
const executedAt = Date.now()
await simulationRepo.save({
await supabaseSimulationRepo.save({
id: uuidv4(),
processId,
executedAt,
@@ -74,14 +71,13 @@ export const useSimulationStore = create<SimulationState>((set) => ({
}),
loadLatestForProcess: async (processId) => {
const sim = await simulationRepo.getLatestByProcess(processId)
const sim = await supabaseSimulationRepo.getLatestByProcess(processId)
set({ lastPersistedExecutedAt: sim?.executedAt ?? null })
},
}))
// Suscripción reactiva: cualquier cambio en activities o currentProcess del process-store
// invalida los resultados para garantizar que nunca haya resultados "viejos" visibles.
// La comparación por referencia de Zustand asegura que solo fires en cambios reales.
useProcessStore.subscribe((state, prevState) => {
if (
state.activities !== prevState.activities ||

View File

@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS public.resources (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
type text NOT NULL CHECK (type IN ('role','person','system','equipment','supply')),
cost_per_hour numeric NOT NULL DEFAULT 0,
created_by uuid REFERENCES public.users(id),
updated_by uuid REFERENCES public.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.resources ENABLE ROW LEVEL SECURITY;
CREATE POLICY "all_resources" ON public.resources
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);

View File

@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS public.activities (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE,
bpmn_element_id text NOT NULL,
name text NOT NULL DEFAULT '',
type text NOT NULL DEFAULT 'task' CHECK (type IN ('task','subprocess')),
direct_cost_fixed numeric NOT NULL DEFAULT 0,
execution_time_minutes numeric NOT NULL DEFAULT 0,
automatable boolean NOT NULL DEFAULT false,
automated_cost_fixed numeric NOT NULL DEFAULT 0,
automated_time_minutes numeric NOT NULL DEFAULT 0,
created_by uuid REFERENCES public.users(id),
updated_by uuid REFERENCES public.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS activities_process_bpmn_idx
ON public.activities(process_id, bpmn_element_id);
ALTER TABLE public.activities ENABLE ROW LEVEL SECURITY;
CREATE POLICY "all_activities" ON public.activities
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);

View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS public.activity_resource_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
activity_id uuid NOT NULL REFERENCES public.activities(id) ON DELETE CASCADE,
resource_id uuid NOT NULL REFERENCES public.resources(id) ON DELETE CASCADE,
utilization_minutes numeric NOT NULL DEFAULT 0,
units numeric NOT NULL DEFAULT 1,
scenario text NOT NULL DEFAULT 'actual' CHECK (scenario IN ('actual','automated'))
);
ALTER TABLE public.activity_resource_assignments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "all_assignments" ON public.activity_resource_assignments
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);

View File

@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS public.gateways (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE,
bpmn_element_id text NOT NULL,
gateway_type text NOT NULL CHECK (gateway_type IN ('exclusive','parallel','inclusive','event-based')),
branches jsonb NOT NULL DEFAULT '[]',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS gateways_process_bpmn_idx
ON public.gateways(process_id, bpmn_element_id);
ALTER TABLE public.gateways ENABLE ROW LEVEL SECURITY;
CREATE POLICY "all_gateways" ON public.gateways
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);

View File

@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS public.simulations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE,
executed_at bigint NOT NULL,
result jsonb NOT NULL,
result_automated jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS simulations_process_executed_idx
ON public.simulations(process_id, executed_at DESC);
ALTER TABLE public.simulations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "all_simulations" ON public.simulations
FOR ALL USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);

View File

@@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS public.user_groups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_by uuid REFERENCES public.users(id),
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.user_groups ENABLE ROW LEVEL SECURITY;
CREATE POLICY "read_groups" ON public.user_groups
FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "manage_groups" ON public.user_groups
FOR ALL USING (
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin')
OR created_by = auth.uid()
);
CREATE TABLE IF NOT EXISTS public.group_memberships (
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
group_id uuid NOT NULL REFERENCES public.user_groups(id) ON DELETE CASCADE,
role text NOT NULL DEFAULT 'member' CHECK (role IN ('owner','member')),
PRIMARY KEY (user_id, group_id)
);
ALTER TABLE public.group_memberships ENABLE ROW LEVEL SECURITY;
CREATE POLICY "read_memberships" ON public.group_memberships
FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "manage_memberships" ON public.group_memberships
FOR ALL USING (
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin')
OR EXISTS (
SELECT 1 FROM public.user_groups
WHERE id = group_memberships.group_id AND created_by = auth.uid()
)
);

View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS public.process_access (
process_id uuid NOT NULL REFERENCES public.processes(id) ON DELETE CASCADE,
grantee_type text NOT NULL CHECK (grantee_type IN ('user','group')),
grantee_id uuid NOT NULL,
permission text NOT NULL DEFAULT 'view' CHECK (permission IN ('view','edit')),
PRIMARY KEY (process_id, grantee_type, grantee_id)
);
ALTER TABLE public.process_access ENABLE ROW LEVEL SECURITY;
CREATE POLICY "read_process_access" ON public.process_access
FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "manage_process_access" ON public.process_access
FOR ALL USING (
EXISTS (SELECT 1 FROM public.users WHERE id = auth.uid() AND platform_role = 'platform_admin')
OR EXISTS (
SELECT 1 FROM public.processes
WHERE id = process_access.process_id AND owner_id = auth.uid()
)
);

View File

@@ -0,0 +1,53 @@
-- Helper: retorna true si el usuario actual es platform_admin
CREATE OR REPLACE FUNCTION public.is_platform_admin()
RETURNS boolean
LANGUAGE sql SECURITY DEFINER
AS $$
SELECT EXISTS (
SELECT 1 FROM public.users
WHERE id = auth.uid() AND platform_role = 'platform_admin'
);
$$;
-- Eliminar política amplia de Sprint 4 Etapa 2
DROP POLICY IF EXISTS "authenticated_all_processes" ON public.processes;
-- Políticas granulares
CREATE POLICY "select_processes" ON public.processes
FOR SELECT USING (
public.is_platform_admin()
OR owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM public.process_access
WHERE process_id = processes.id
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR (grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
))
)
)
);
CREATE POLICY "insert_processes" ON public.processes
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY "update_processes" ON public.processes
FOR UPDATE USING (
public.is_platform_admin()
OR owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM public.process_access
WHERE process_id = processes.id
AND permission = 'edit'
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR (grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
))
)
)
);
CREATE POLICY "delete_processes" ON public.processes
FOR DELETE USING (public.is_platform_admin() OR owner_id = auth.uid());

View File

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

View File

@@ -590,7 +590,7 @@ const mockProcessBase = {
currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000,
groupId: null, tags: [],
groupId: null, tags: [], ownerId: 'test-user',
}
const mockSimulationBase = {

View File

@@ -43,6 +43,10 @@ vi.mock('@/store/simulation-store', () => ({
useSimulationStore: () => simulationStoreMock,
}))
vi.mock('@/auth/AuthContext', () => ({
useAuth: () => ({ user: { id: 'test-user-id' } }),
}))
import { ActivityPanel } from '@/features/workspace/ActivityPanel'
// ─── Helper ───────────────────────────────────────────────────────────────────

View File

@@ -34,6 +34,10 @@ vi.mock('@/store/simulation-store', () => ({
}),
}))
vi.mock('@/auth/AuthContext', () => ({
useAuth: () => ({ user: { id: 'test-user-id' } }),
}))
import { GlobalSettingsPanel } from '@/features/workspace/GlobalSettingsPanel'
// ─── Helper ───────────────────────────────────────────────────────────────────

View File

@@ -1,148 +0,0 @@
/**
* Tests de migración Dexie v2 → v3 (Sprint 2).
*
* Verifican que:
* 1. ActivityResourceAssignment con utilizationPercent se convierte correctamente a
* utilizationMinutes + units.
* 2. Actividades sin assignedResources mantienen array vacío.
* 3. Actividades con assignedResources vacío no fallan.
* 4. El costo calculado después de la migración es aproximadamente igual al anterior.
* 5. La migración es idempotente (si ya está en nuevo formato, no se rompe).
*
* Usan fake-indexeddb (parcheado en tests/setup.ts).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { db } from '@/persistence/db'
import { activityRepo } from '@/persistence/repositories'
import type { Activity } from '@/domain/types'
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeActivity(overrides: Partial<Activity> & { id: string; processId: string }): Activity {
return {
id: overrides.id,
processId: overrides.processId,
bpmnElementId: overrides.bpmnElementId ?? `task_${overrides.id}`,
name: overrides.name ?? `Tarea ${overrides.id}`,
type: 'task',
directCostFixed: overrides.directCostFixed ?? 500,
executionTimeMinutes: overrides.executionTimeMinutes ?? 60,
assignedResources: overrides.assignedResources ?? [],
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
automatedAssignedResources: [],
}
}
// ─── Setup / Teardown ─────────────────────────────────────────────────────────
beforeEach(async () => {
await db.open()
await db.transaction('rw', [db.activities], async () => {
await db.activities.clear()
})
})
afterEach(async () => {
await db.close()
})
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('Migración Dexie v3 — ActivityResourceAssignment: utilizationPercent → utilizationMinutes + units', () => {
it('actividad sin assignedResources mantiene array vacío', async () => {
const act = makeActivity({ id: 'a1', processId: 'p1', assignedResources: [] })
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(retrieved.assignedResources).toEqual([])
})
it('actividad con assignedResources vacío no falla al recuperar', async () => {
const act = makeActivity({ id: 'a2', processId: 'p1' })
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(Array.isArray(retrieved.assignedResources)).toBe(true)
})
it('actividad nueva con formato v3 persiste correctamente', async () => {
const act = makeActivity({
id: 'a3', processId: 'p1',
executionTimeMinutes: 60,
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }],
})
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(retrieved.assignedResources).toHaveLength(1)
expect(retrieved.assignedResources[0]).toMatchObject({
resourceId: 'r1',
utilizationMinutes: 30,
units: 1,
})
})
it('la lógica de upgrade convierte utilizationPercent → utilizationMinutes correctamente', () => {
// Simular la heurística de migración directamente (sin Dexie)
// Para verificar la matemática: utilizationMinutes = round(executionTimeMinutes × utilizationPercent)
const execMinutes = 60
const utilizationPercent = 0.5 // 50% del tiempo
const expectedMinutes = Math.round(execMinutes * utilizationPercent) // = 30
expect(expectedMinutes).toBe(30)
})
it('heurística de migración con percent=1.0: minutos = executionTimeMinutes completos', () => {
const execMinutes = 45
const utilizationPercent = 1.0
const migratedMinutes = Math.round(execMinutes * utilizationPercent)
expect(migratedMinutes).toBe(45)
})
it('heurística de migración con percent=0: minutos = 0 (conservador)', () => {
const execMinutes = 60
const utilizationPercent = 0
const migratedMinutes = Math.round(execMinutes * utilizationPercent)
expect(migratedMinutes).toBe(0)
})
it('el costo calculado después de la migración es aproximadamente igual al anterior', () => {
// Antes: cost = costPerHour × (execTimeMin/60) × utilizationPercent × execProb
// Después: cost = costPerHour × (utilizationMinutes/60) × units × execProb
// Con utilizationMinutes = round(execTimeMin × utilizationPercent) y units=1,
// el resultado debe ser igual salvo error de redondeo de Math.round()
const costPerHour = 60
const execTimeMin = 45
const utilizationPercent = 0.5
const execProb = 0.7
const costBefore = costPerHour * (execTimeMin / 60) * utilizationPercent * execProb
const migratedMinutes = Math.round(execTimeMin * utilizationPercent) // = 23 (round de 22.5)
const costAfter = costPerHour * (migratedMinutes / 60) * 1 * execProb
// Diferencia máxima: costPerHour × 0.5/60 × execProb (error de 0.5 minutos de redondeo)
const maxDiff = costPerHour * (0.5 / 60) * execProb
expect(Math.abs(costAfter - costBefore)).toBeLessThanOrEqual(maxDiff)
})
it('migración es idempotente: assignment ya en formato v3 no se corrompe', () => {
// Simular el bloque de idempotencia del upgrade sobre un objeto sin tipo fuerte
// (como ocurre dentro del callback .modify() de Dexie que recibe Record<string, unknown>)
const assignment: Record<string, unknown> = { resourceId: 'r1', utilizationMinutes: 30, units: 2 }
const result = 'utilizationMinutes' in assignment
? { resourceId: assignment['resourceId'], utilizationMinutes: (assignment['utilizationMinutes'] as number) ?? 0, units: (assignment['units'] as number) ?? 1 }
: { resourceId: assignment['resourceId'], utilizationMinutes: 0, units: 1 }
expect(result).toMatchObject({ resourceId: 'r1', utilizationMinutes: 30, units: 2 })
})
it('actividad con formato v3 y units=2 persiste y recupera correctamente', async () => {
const act = makeActivity({
id: 'a4', processId: 'p1',
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 20, units: 2 }],
})
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(retrieved.assignedResources[0].units).toBe(2)
expect(retrieved.assignedResources[0].utilizationMinutes).toBe(20)
})
})

View File

@@ -1,151 +0,0 @@
/**
* Tests de migración Dexie v1 → v2 (Sprint 1).
*
* Verifican que:
* 1. Registros sin los campos nuevos reciben defaults correctos tras el upgrade.
* 2. Registros con los campos ya presentes no son modificados.
* 3. Los nuevos campos se pueden leer y persistir correctamente.
*
* Usan fake-indexeddb (parcheado en tests/setup.ts).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { db } from '@/persistence/db'
import { activityRepo, processRepo } from '@/persistence/repositories'
import type { Activity, Process } from '@/domain/types'
// ─── Fixtures de registros "viejos" (sin campos Sprint 1) ─────────────────────
function makeLegacyActivity(id: string, processId: string): Omit<Activity, 'automatable' | 'automatedCostFixed' | 'automatedTimeMinutes' | 'automatedAssignedResources'> {
return {
id, processId,
bpmnElementId: `task_${id}`,
name: `Tarea ${id}`,
type: 'task',
directCostFixed: 500,
executionTimeMinutes: 45,
assignedResources: [],
}
}
function makeLegacyProcess(id: string): Omit<Process, 'annualFrequency' | 'analysisHorizonYears' | 'automationInvestment' | 'groupId' | 'tags'> {
return {
id, name: 'Proceso Legacy', clientName: 'Cliente Viejo',
bpmnXml: '<definitions/>', currency: 'USD', overheadPercentage: 0.15,
createdAt: 1000, updatedAt: 2000,
}
}
// ─── Setup / Teardown ─────────────────────────────────────────────────────────
beforeEach(async () => {
await db.open()
await db.transaction('rw', [db.processes, db.activities], async () => {
await db.processes.clear()
await db.activities.clear()
})
})
afterEach(async () => {
await db.close()
})
// ─── Tests de defaults tras migración ─────────────────────────────────────────
describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () => {
it('actividades nuevas tienen automatable=false por defecto', async () => {
const act: Activity = {
...makeLegacyActivity('a1', 'p1'),
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
automatedAssignedResources: [],
}
await activityRepo.save(act)
const retrieved = await activityRepo.getByProcess('p1')
expect(retrieved[0].automatable).toBe(false)
expect(retrieved[0].automatedCostFixed).toBe(0)
expect(retrieved[0].automatedTimeMinutes).toBe(0)
})
it('los campos de automatización persisten con valores no-default', async () => {
const act: Activity = {
...makeLegacyActivity('a2', 'p1'),
automatable: true,
automatedCostFixed: 12.5,
automatedTimeMinutes: 3,
automatedAssignedResources: [],
}
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(retrieved.automatable).toBe(true)
expect(retrieved.automatedCostFixed).toBe(12.5)
expect(retrieved.automatedTimeMinutes).toBe(3)
})
it('update de actividad preserva los campos de automatización existentes', async () => {
const act: Activity = {
...makeLegacyActivity('a3', 'p1'),
automatable: true,
automatedCostFixed: 5,
automatedTimeMinutes: 10,
automatedAssignedResources: [],
}
await activityRepo.save(act)
// Actualizar solo directCostFixed — los campos de automatización no deben cambiar
await activityRepo.save({ ...act, directCostFixed: 999 })
const [updated] = await activityRepo.getByProcess('p1')
expect(updated.directCostFixed).toBe(999)
expect(updated.automatable).toBe(true)
expect(updated.automatedCostFixed).toBe(5)
})
})
describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => {
it('procesos nuevos tienen annualFrequency=1000 por defecto', async () => {
const proc: Process = {
...makeLegacyProcess('proc-1'),
annualFrequency: 1000,
analysisHorizonYears: 3,
automationInvestment: 0,
groupId: null, tags: [],
}
await processRepo.save(proc)
const retrieved = await processRepo.getById('proc-1')
expect(retrieved!.annualFrequency).toBe(1000)
expect(retrieved!.analysisHorizonYears).toBe(3)
expect(retrieved!.automationInvestment).toBe(0)
})
it('los campos de volumetría persisten con valores configurados', async () => {
const proc: Process = {
...makeLegacyProcess('proc-2'),
annualFrequency: 5000,
analysisHorizonYears: 5,
automationInvestment: 75_000,
groupId: null, tags: [],
}
await processRepo.save(proc)
const retrieved = await processRepo.getById('proc-2')
expect(retrieved!.annualFrequency).toBe(5000)
expect(retrieved!.analysisHorizonYears).toBe(5)
expect(retrieved!.automationInvestment).toBe(75_000)
})
it('update de proceso preserva campos de volumetría', async () => {
const proc: Process = {
...makeLegacyProcess('proc-3'),
annualFrequency: 2000,
analysisHorizonYears: 4,
automationInvestment: 30_000,
groupId: null, tags: [],
}
await processRepo.save(proc)
// Actualizar solo el nombre — los campos de volumetría no deben cambiar
await processRepo.save({ ...proc, name: 'Nombre Actualizado' })
const updated = await processRepo.getById('proc-3')
expect(updated!.name).toBe('Nombre Actualizado')
expect(updated!.annualFrequency).toBe(2000)
expect(updated!.automationInvestment).toBe(30_000)
})
})

View File

@@ -1,341 +0,0 @@
/**
* 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,
annualFrequency: 1000,
analysisHorizonYears: 3,
automationInvestment: 0,
createdAt: 1000,
updatedAt: 2000,
groupId: null,
tags: [],
}
}
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', utilizationMinutes: 15, units: 1 }],
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
automatedAssignedResources: [],
}
}
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].utilizationMinutes).toBe(15)
expect(retrieved.assignedResources[0].units).toBe(1)
})
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

@@ -14,7 +14,7 @@ function makeProcess(extras: Partial<Process> = {}): Process {
id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ',
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ...extras,
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', ...extras,
}
}

View File

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

View File

@@ -66,7 +66,7 @@ describe('Medición de tamaños de CSV — 3 sample BPMNs', () => {
bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0,
groupId: null, tags: [],
groupId: null, tags: [], ownerId: 'test-user',
}
const simulation: Simulation = {

View File

@@ -51,7 +51,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: 0, updatedAt: 0,
groupId: null, tags: [],
groupId: null, tags: [], ownerId: 'test-user',
}
const perActivityActual = [

View File

@@ -43,7 +43,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0,
groupId: null, tags: [],
groupId: null, tags: [], ownerId: 'test-user',
}
// Simulación SIN recursos en ninguna actividad

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,220 +0,0 @@
/**
* Tests de migración Dexie v3 → v4 (Sprint 3).
*
* Verifican que:
* 1. Proceso existente conserva todos sus campos y recibe groupId=null, tags=[].
* 2. Activity existente conserva assignedResources y recibe automatedAssignedResources=[].
* 3. Settings singleton creado con groupLabel='Cliente' durante el upgrade.
* 4. El upgrade es idempotente: datos ya migrados no se corrompen.
* 5. groupId puede ser un UUID válido y es queryable como índice.
*
* Usan fake-indexeddb (parcheado en tests/setup.ts).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { db } from '@/persistence/db'
import type { Process, ProcessGroup } from '@/domain/types'
// ─── Helper ───────────────────────────────────────────────────────────────────
function makeProcess(id: string, overrides: Partial<Process> = {}): Process {
return {
id,
name: `Proceso ${id}`,
clientName: 'Cliente Test',
bpmnXml: '<definitions/>',
currency: 'USD',
overheadPercentage: 0.2,
annualFrequency: 1000,
analysisHorizonYears: 3,
automationInvestment: 0,
createdAt: 1000,
updatedAt: 2000,
groupId: null,
tags: [],
...overrides,
}
}
// ─── Test 1 — Process: lógica del upgrade (puro, sin base de datos) ───────────
describe('Migración Dexie v4 — Process: lógica de upgrade', () => {
it('proceso v3 (sin groupId ni tags) recibe defaults correctos preservando campos originales', () => {
const proc: Record<string, unknown> = {
id: 'p1',
name: 'Proceso Legado',
clientName: 'Cliente A',
bpmnXml: '<definitions/>',
currency: 'PYG',
overheadPercentage: 0.15,
annualFrequency: 500,
analysisHorizonYears: 5,
automationInvestment: 50_000,
createdAt: 1000,
updatedAt: 2000,
// Sin groupId ni tags — formato v3
}
// Simular callback del upgrade
if (proc.groupId === undefined) proc.groupId = null
if (!Array.isArray(proc.tags)) proc.tags = []
// Campos originales intactos
expect(proc.id).toBe('p1')
expect(proc.name).toBe('Proceso Legado')
expect(proc.clientName).toBe('Cliente A')
expect(proc.currency).toBe('PYG')
expect(proc.overheadPercentage).toBe(0.15)
expect(proc.annualFrequency).toBe(500)
expect(proc.analysisHorizonYears).toBe(5)
expect(proc.automationInvestment).toBe(50_000)
expect(proc.createdAt).toBe(1000)
expect(proc.updatedAt).toBe(2000)
// Nuevos campos con defaults correctos
expect(proc.groupId).toBeNull()
expect(proc.tags).toEqual([])
})
})
// ─── Test 2 — Activity: lógica del upgrade (puro, sin base de datos) ─────────
describe('Migración Dexie v4 — Activity: lógica de upgrade', () => {
it('activity v3 con assignedResources poblados recibe automatedAssignedResources=[]', () => {
const act: Record<string, unknown> = {
id: 'a1',
processId: 'p1',
bpmnElementId: 'task_a1',
name: 'Tarea A',
type: 'task',
directCostFixed: 500,
executionTimeMinutes: 60,
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }],
automatable: true,
automatedCostFixed: 200,
automatedTimeMinutes: 15,
// Sin automatedAssignedResources — formato v3
}
// Simular callback del upgrade
if (!Array.isArray(act.automatedAssignedResources)) {
act.automatedAssignedResources = []
}
// assignedResources no fue alterado
expect(act.assignedResources).toEqual([{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }])
// campos de automatización intactos
expect(act.automatable).toBe(true)
expect(act.automatedCostFixed).toBe(200)
expect(act.automatedTimeMinutes).toBe(15)
// nuevo campo vacío
expect(act.automatedAssignedResources).toEqual([])
})
})
// ─── Test 3 — Settings singleton (integración) ───────────────────────────────
describe('Migración Dexie v4 — Settings singleton', () => {
beforeEach(async () => {
await db.open()
})
afterEach(async () => {
await db.close()
})
it('singleton creado con groupLabel=Cliente durante el upgrade', async () => {
const settings = await db.settings.get('singleton')
expect(settings).toBeDefined()
expect(settings!.id).toBe('singleton')
expect(settings!.groupLabel).toBe('Cliente')
})
})
// ─── Test 4 — Idempotencia (puro, sin base de datos) ─────────────────────────
describe('Migración Dexie v4 — Idempotencia', () => {
it('upgrade no corrompe proceso ya migrado (groupId y tags con valores)', () => {
const proc: Record<string, unknown> = {
id: 'p2',
groupId: null,
tags: ['facturación', 'BPMN'],
}
// Simular upgrade (idempotente)
if (proc.groupId === undefined) proc.groupId = null
if (!Array.isArray(proc.tags)) proc.tags = []
expect(proc.groupId).toBeNull()
expect(proc.tags).toEqual(['facturación', 'BPMN'])
})
it('upgrade no corrompe activity ya migrada (automatedAssignedResources poblado)', () => {
const act: Record<string, unknown> = {
id: 'a2',
automatedAssignedResources: [{ resourceId: 'r2', utilizationMinutes: 10, units: 2 }],
}
if (!Array.isArray(act.automatedAssignedResources)) {
act.automatedAssignedResources = []
}
expect(act.automatedAssignedResources).toEqual([
{ resourceId: 'r2', utilizationMinutes: 10, units: 2 },
])
})
})
// ─── Test 5 — groupId como UUID válido (integración) ─────────────────────────
describe('Migración Dexie v4 — groupId como UUID válido', () => {
beforeEach(async () => {
await db.open()
await db.transaction('rw', [db.processes, db.groups], async () => {
await db.processes.clear()
await db.groups.clear()
})
})
afterEach(async () => {
await db.close()
})
it('proceso con groupId persiste y es queryable por el índice groupId', async () => {
const group: ProcessGroup = {
id: 'group-abc-123',
name: 'Grupo Test',
createdAt: 1000,
updatedAt: 2000,
}
await db.groups.put(group)
const proc: Process = makeProcess('p3', {
groupId: 'group-abc-123',
tags: ['test'],
})
await db.processes.put(proc)
// El campo persiste
const retrieved = await db.processes.get('p3')
expect(retrieved!.groupId).toBe('group-abc-123')
expect(retrieved!.tags).toEqual(['test'])
// El índice funciona para query por grupo
const byGroup = await db.processes.where('groupId').equals('group-abc-123').toArray()
expect(byGroup).toHaveLength(1)
expect(byGroup[0].id).toBe('p3')
})
it('proceso con groupId=null persiste y es recuperable', async () => {
const proc: Process = makeProcess('p4', { groupId: null, tags: [] })
await db.processes.put(proc)
const retrieved = await db.processes.get('p4')
expect(retrieved).toBeDefined()
expect(retrieved!.groupId).toBeNull()
// Verificar con filter (Dexie no acepta null en .equals())
const sinGrupo = await db.processes.filter((p) => p.groupId == null).toArray()
expect(sinGrupo.some((p) => p.id === 'p4')).toBe(true)
})
})

View File

@@ -94,7 +94,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000,
createdAt: Date.now(), updatedAt: Date.now(),
groupId: null, tags: [],
groupId: null, tags: [], ownerId: 'test-user',
}
useProcessStore.setState({ currentProcess: proc })