Corrección 1 — Delete de proceso restaurado + Corrección 2 — Patrón userId en repos

This commit is contained in:
2026-05-28 01:13:28 -03:00
parent 1bc80e190f
commit 0a3c60cd09
26 changed files with 1650 additions and 25844 deletions

View File

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

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.

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*

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

@@ -9,6 +9,7 @@ import { supabaseProcessRepo } from '@/persistence/supabase/process-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 +37,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)
@@ -113,7 +115,7 @@ export function ImportPage() {
})
await Promise.all([
supabaseProcessRepo.save(process),
supabaseProcessRepo.save(process, user!.id),
activityRepo.saveMany(activities),
gatewayRepo.saveMany(gateways),
])

View File

@@ -3,8 +3,10 @@ import { useNavigate } from '@tanstack/react-router'
import {
Upload, Search, Plus, Building2, FolderX, GitBranch,
Clock, TrendingUp, BarChart2, FileX2, X, Settings,
MoreVertical, Trash2,
} from 'lucide-react'
import { useLibraryStore } from '@/store/library-store'
import { useAuth } from '@/auth/AuthContext'
import { formatCurrency } from '@/lib/format'
import type { Process, ProcessGroup, Simulation } from '@/domain/types'
import { AppHeader } from '@/components/AppHeader'
@@ -78,18 +80,31 @@ function KpiBadge({ process, simulation }: { process: Process; simulation: Simul
// ─── Process Card ─────────────────────────────────────────────────────────────
function ProcessCard({ process, simulation, showGroupChip, groupName }: {
function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete }: {
process: Process
simulation: Simulation | null | undefined
showGroupChip?: boolean
groupName?: string
onDelete?: () => void
}) {
const navigate = useNavigate()
const [menuState, setMenuState] = useState<null | 'menu' | 'confirm'>(null)
// 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"
@@ -125,6 +140,60 @@ 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-[130px]"
onClick={(e) => e.stopPropagation()}
>
<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>
)
}
@@ -244,6 +313,7 @@ function HomeView({
onImport: (groupId?: string) => void
}) {
const { groups, processes, settings, createGroup } = useLibraryStore()
const { user } = useAuth()
const [search, setSearch] = useState('')
const [activeTags, setActiveTags] = useState<string[]>([])
@@ -402,7 +472,7 @@ function HomeView({
})}
<NewGroupCard
label={settings.groupLabel}
onCreate={(name) => createGroup(name)}
onCreate={(name) => createGroup(name, user!.id)}
/>
</div>
@@ -437,7 +507,7 @@ function ProcessCardWithSim({ process, showGroupChip, groupName }: {
showGroupChip?: boolean
groupName?: string
}) {
const { getLatestSimulation } = useLibraryStore()
const { getLatestSimulation, deleteProcess } = useLibraryStore()
const [simulation, setSimulation] = useState<Simulation | null | undefined>(undefined)
useEffect(() => {
@@ -448,7 +518,15 @@ 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)}
/>
)
}
// ─── Group View ───────────────────────────────────────────────────────────────

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

@@ -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)
}

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,7 +67,7 @@ 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(),
@@ -74,7 +76,7 @@ export function ResourcesPanel() {
type: form.type,
costPerHour,
}
await addResource(newResource)
await addResource(newResource, user!.id)
}
cancelForm()
}
@@ -186,7 +188,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,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

@@ -2,12 +2,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,
@@ -47,8 +41,7 @@ function fromRow(row: Record<string, unknown>): Process {
}
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
},

View File

@@ -14,12 +14,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>
@@ -51,15 +51,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,29 +67,29 @@ 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) => {
getLatestSimulation: (processId) => {
return simulationRepo.getLatestByProcess(processId)
},

View File

@@ -12,13 +12,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
}
@@ -46,8 +46,8 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
}
},
setCurrentProcess: async (process) => {
await supabaseProcessRepo.save(process)
setCurrentProcess: async (process, userId) => {
await supabaseProcessRepo.save(process, userId)
const [activities, gateways, resources] = await Promise.all([
activityRepo.getByProcess(process.id),
gatewayRepo.getByProcess(process.id),
@@ -56,20 +56,20 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
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) => {
updateActivity: async (activity, userId) => {
await activityRepo.save(activity)
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 +81,12 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
}
},
updateGateway: async (gateway) => {
updateGateway: async (gateway, userId) => {
await gatewayRepo.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 +98,24 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
}
},
addResource: async (resource) => {
addResource: async (resource, userId) => {
await resourceRepo.save(resource)
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) => {
updateResource: async (resource, userId) => {
await resourceRepo.save(resource)
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 +127,12 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
}
},
deleteResource: async (resourceId) => {
deleteResource: async (resourceId, userId) => {
await resourceRepo.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

@@ -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 ───────────────────────────────────────────────────────────────────