Compare commits

...

109 Commits

Author SHA1 Message Date
8b839baf77 refactor(router): guards AdminLayout + ImportPage → RequireRole wrapper (Etapa 6A)
Some checks are pending
/ Deploy to Cloudflare Pages (push) Waiting to run
- Crea RequireRole component (Approach B — router sin auth context)
- AdminLayout: elimina useEffect + spinner + null check, usa <RequireRole roles={['platform_admin']}>
- ImportPage: elimina useEffect guard + return null client_viewer, usa <RequireRole roles={['platform_admin','member','client_editor']}>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 13:18:14 -03:00
1032944f8b fix import para client_editor
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-09 12:10:36 -03:00
0257dd95ec Otro fix para el import con client_editor
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-08 11:54:18 -03:00
fdbdc42470 El bug está en toRow() en process-repo.ts, no en el ImportPage:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
typescript// toRow() en process-repo.ts — líneas 4-24
// NUNCA incluye org_id en el payload del upsert
const row = {
  id: process.id,
  name: process.name,
  // ... otros campos
  // ← org_id ausente completamente
}
La cadena completa del fallo:

ImportPage.tsx sí tiene orgId: user!.orgId ?? null → el Process object tiene el valor correcto
process-repo.ts llama toRow() para construir el payload de Supabase
toRow() nunca pone org_id en el payload → Supabase recibe org_id = null (default)
La RLS chequea org_id = current_org() → null ≠ '2a956076-...' → 403
El error propaga y sale como toast genérico de archivo dañado

Esto pasó desapercibido hasta ahora porque platform_admin tiene is_platform_admin() que bypasea la verificación de org_id. La inserción con org_id = null le funciona a admins, nunca a client_editor.
El fix es quirúrgico: agregar una sola línea en toRow():
typescriptorg_id: process.orgId ?? null,
Eso es todo. Una línea en process-repo.ts. La restricción "no tocar process-repo.ts" de Etapa 5 era por razón equivocada (asumíamos que el repo no necesitaba cambios) — este es un bug que impide que Etapa 5 funcione en absoluto.
2026-07-08 11:46:29 -03:00
3044342ac1 etapa 5
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 23:38:38 -03:00
85190aef0c docs(changelog): etapa 5 sprint 8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:18:41 -03:00
789e9a34ea feat(library): botón Importar BPMN visible para client_editor; empty state diferenciado
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:18:25 -03:00
bff71f0f29 feat(import): client_editor puede importar BPMN; orgId asignado automáticamente
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:18:16 -03:00
70d35dad8b feat(db): migración 018 — INSERT policy para client_editor en processes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:18:11 -03:00
54306be1c2 Etapa 4.5 entregada.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 22:02:19 -03:00
d776af5f24 docs(changelog): etapa 4.5 sprint 8
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:57:40 -03:00
cc5563974a fix(library): scroll a sección de org al volver del workspace con ?org param
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:57:29 -03:00
c6c88501d1 fix(workspace): handleBack pasa ?org=orgId para scroll contextual en biblioteca
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:57:25 -03:00
1813a9396a fix(admin): InQuality visible en OrganizationsPage con badge — quitar filtro is_provider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:57:20 -03:00
b149e1cd0c fix(workspace): sentinel '__none__' en selector área — Radix UI prohíbe value='' en SelectItem
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:18:58 -03:00
53f8a8d8cd etapa 4 terminada
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 21:12:00 -03:00
6e60bf4def docs(changelog): etapa 4 sprint 8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:06:59 -03:00
fe33c910ac feat(admin): CRUD de áreas en OrganizationDetailPage — tab Áreas con crear/renombrar/eliminar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:06:46 -03:00
b634d061b7 feat(workspace): topbar muestra areaName en lugar de clientName inline-editable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:06:42 -03:00
e0b4607603 feat(workspace): selector de área en GlobalSettingsPanel reemplaza campo libre "Cliente"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:06:39 -03:00
de00e3a902 etapa 3 terminada
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 20:19:45 -03:00
aa0ab72f17 docs(changelog): etapa 3 sprint 8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 20:17:19 -03:00
cbf3a88e78 feat(library): agrupación org → área reemplaza groupId en HomeView
- Admin ve procesos agrupados por org (colapsable) → sub-secciones por área
- Búsqueda muestra chip con areaName ?? orgName en lugar de nombre de grupo
- Header muestra conteo de clientes (orgs) en vez de groups
- Botón Settings ⚙ oculto para client_editor y client_viewer
- Efecto orgParam/groupId scroll obsoleto eliminado
- handleImport simplificado (sin groupId)
- data-testid="groups-header" preservado en el nuevo contenedor admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 20:17:08 -03:00
b99cc75f80 feat(router): guard beforeLoad en /settings para client_editor y client_viewer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 20:16:56 -03:00
27336fe0c7 fix(process-repo): getAll() incluye JOIN con org y area para populear names
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 20:16:55 -03:00
e8490a5863 docs(changelog): etapa 2 sprint 8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 18:18:23 -03:00
d6e7226842 feat(process-repo): extender fromRow/toRow/getById con areaId y areaName
- fromRow: extrae areaId y areaName (vía JOIN en getById)
- toRow y updateEditable: incluyen area_id en escrituras a Supabase
- getById: JOIN con areas!area_id(name) para obtener nombre en una query
- Fixtures de tests actualizados con areaId: null (campo requerido)
- 4 tests nuevos en tests/persistence/area-repo.test.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 18:18:14 -03:00
f9e886e12d feat(area-repo): repositorio CRUD de áreas por organización
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 18:18:07 -03:00
a3ff6ee545 feat(domain): interface Area + campos areaId/areaName en Process
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 18:18:04 -03:00
d64078d96a Etapa 0 aprobada condicionalmente. Queda pendiente tu validación visual: abrí /library en https://inq-roi.inqualityhq.com y confirmá que el footer muestra InQ ROI · Powered by InQuality · v0.8.0 · Hecho desde Paraguay 🇵🇾.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 17:55:19 -03:00
10e477cc45 docs(sprint-8): ETAPA_0_PROMPT — versionado, CHANGELOG, AppFooter
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 17:47:43 -03:00
ec77a7fffa docs(sprint-8): ETAPA_0_PROMPT — versionado, CHANGELOG, AppFooter 2026-07-07 17:47:31 -03:00
0426f67940 docs(sprint-8): BRIEF.md — consolidación org→areas, client_editor BPMN, versionado, Bloque B
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 17:39:36 -03:00
9277143822 docs(sprint-7): cierre formal — TECH_DEBT guards+auth, OBSERVATIONS reset Sprint 8, decisión org→areas
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 17:29:48 -03:00
ee7cdf5981 chore(workspace): eliminar console.log de diagnóstico de orgId 2026-07-07 17:27:23 -03:00
7102d6ade6 fix(workspace): back button con orgName via JOIN + navegación simplificada a /library
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 17:21:37 -03:00
c92f4f087a fix(workspace): handleBack navega a /library sin ?org= hasta que exista vista filtrada
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 17:13:07 -03:00
df5fb55696 fix(workspace): obtener orgName via JOIN en getById, eliminar fetch separado con race condition
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 15:26:52 -03:00
6fee592e40 Resumen del fix — Prioridad 2:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Cambios en WorkspacePage.tsx:

orgName ahora es string | null | undefined. undefined = query no completó aún, null = proceso sin org (o query sin resultado), string = nombre real.
Reemplazó .single() por .maybeSingle() — .single() lanzaba PGRST116 cuando el org no existía, data quedaba en null y setOrgName nunca se llamaba, dejando el estado indefinido.
setOrgName(data?.name ?? null) se llama siempre, sin condición if (data).
backLabel pasa de orgId ? (orgName ?? 'Volver') : 'Biblioteca' a simplemente orgName ?? 'Biblioteca' — el nombre real si cargó, o el fallback genérico en cualquier otro caso. Ya no depende de orgId para decidir el label.
handleBack() no cambió — sigue usando currentProcess?.orgId para construir el URL correcto a /library?org=xxx.
Listo para validación visual en workspace con un proceso de cliente que tenga org_id en la DB.
2026-07-07 14:53:39 -03:00
f3f55a6857 Resumen de los cambios del fix de navegación:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
LibraryPage.tsx:778 — reemplazó useSearch({ from: '/' }) por useRouterState + URLSearchParams. Ahora funciona tanto desde / como desde /library.
WorkspacePage.tsx:206-209 — handleBack() navega a /library (con o sin ?org=uuid) en lugar de /.
workspace-back-button.test.ts — backTo actualizado a /library y /library?org=... en los 6 tests.
library-ui.test.tsx — mock de useSearch → useRouterState (devuelve { location: { search: '' } }).
2026-07-07 14:24:26 -03:00
ee356e5de6 Etapa 7 ya estaba completamente ejecutada en esta sesión. Todo está en su lugar:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Fix A1 — isProfileLoaded en AuthContext 

useState(false) inicial
setIsProfileLoaded(true) si onAuthStateChange devuelve null (no hay usuario que sincronizar)
setIsProfileLoaded(false) en SIGNED_IN (resetea para el nuevo sync)
setIsProfileLoaded(true) en el finally del sync effect (después de syncProfileFromDB, éxito o error)
Fix A2 — Guard en LibraryPage 

if (!user || !isProfileLoaded) devuelve skeleton animado antes de cualquier contenido rol-dependiente
Fix A3 — Campo "cliente" read-only para client_editor 

GlobalSettingsPanel.tsx: <p> estático en lugar de <Input> cuando isClientRole
WorkspacePage.tsx: topbar muestra el valor sin input ni lápiz para client_editor
Fix B — Migración 016 + aplicada a DB 

Archivo supabase/migrations/016_add_simulations_update_policy.sql creado
Policy simulations_update confirmada activa en DB (pg_policies devuelve las 4 policies de simulations)
Fix adicional — 403 en POST /processes  (ejecutado en el turno inmediato anterior)

updateEditable() en process-repo.ts usa .update() en lugar de .upsert() para todos los paths de edición
Tests: 586 verdes, build limpio.

Lo que falta según el checklist del prompt es la validación visual del director (msebem@gmail.com en la app real). Eso requiere tu OK después de verificar en el navegador.

domain/types.ts — orgId: string | null agregado a Process (campo de DB ya existente, ahora mapeado en el dominio).

process-repo.ts — fromRow incluye orgId: (row.org_id as string | null) ?? null. Ambas queries (getById y getAll) usan select('*'), por lo que el campo viene gratis.

WorkspacePage.tsx:

Import supabase para el fetch de org name
Estado orgName + useEffect que hace SELECT name FROM organizations WHERE id = orgId cuando cambia currentProcess?.orgId
Variable isClientRole (solo client_editor — client_viewer ya fue redirigido)
backLabel: "Procesos" para client_editor, nombre de org (o "Volver" mientras carga) si hay orgId, "Biblioteca" si no hay
handleBack(): navega a /?org=<orgId> para admin con proceso de cliente, a / para el resto
El Home button + Tooltip fueron reemplazados por <Button variant="ghost" size="sm"> con ArrowLeft + backLabel
LibraryPage.tsx:

Import useSearch
Lee orgParam = useSearch({ from: '/' }) as { org?: string }
useEffect que, cuando orgParam está presente y la carga terminó, busca el proceso con p.orgId === orgParam, obtiene su groupId, y hace scrollIntoView al elemento group-{groupId}
Los GroupCards en el grid están ahora envueltos en <div id="group-{group.id}"> para ser alcanzables por el scroll
Tests — workspace-back-button.test.ts con 6 casos: admin+orgName, admin+orgName loading, admin sin org, member sin org, client_editor, client_viewer. Fixtures actualizados en 13 archivos con orgId: null. Mock de useSearch agregado en library-ui.test.tsx.
2026-07-07 14:12:10 -03:00
cad8fbca4f Causa del 403: Supabase evalúa siempre la política INSERT cuando se usa .upsert(), incluso si la fila ya existe. client_editor no tiene INSERT en processes, entonces cualquier .upsert() sobre procesos existentes fallaba con 403.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Fix: nuevo método updateEditable() en process-repo.ts que usa .update().eq('id', process.id) con solo los campos editables (sin bpmn_xml, owner_id, created_by, id). Todos los paths de edición en process-store.ts y library-store.ts ahora usan updateEditable(). El .upsert() original queda únicamente para la creación inicial desde ImportPage y setCurrentProcess — rutas que client_editor nunca puede alcanzar (el import está bloqueado por el guard de rol).
2026-07-07 13:25:43 -03:00
9b89cb8669 La política simulations_update está activa en la DB de desarrollo. El fix B está completo.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Resumen de todo lo entregado en Etapa 7:

Fix A1 (src/auth/AuthContext.tsx): isProfileLoaded: boolean agregado como estado. Arranca en false, se pone true en el finally del sync effect (después de syncProfileFromDB), y cuando onAuthStateChange devuelve null (usuario no autenticado). Se resetea a false en cada SIGN_IN para gatear el re-sync.

Fix A2 (src/features/library/LibraryPage.tsx): Guard temprano después de todos los hooks — si !user || !isProfileLoaded devuelve un skeleton de tres columnas animado, idéntico al que usa HomeView. El contenido rol-dependiente solo se renderiza cuando el perfil está sincronizado.

Fix A3 (GlobalSettingsPanel.tsx + WorkspacePage.tsx): El campo "Cliente" es texto estático (<p>) para client_editor y client_viewer. En el topbar de WorkspacePage se oculta el input inline y el ícono de lápiz; si hay un clientName se muestra como texto inerte.

Fix B (supabase/migrations/016_add_simulations_update_policy.sql): Migración creada + aplicada vía Management API a inq-roi-desa. pg_policies confirma las 4 políticas: simulations_select, simulations_insert, simulations_update, simulations_delete.

Tests: tests/features/library/library-ui.test.tsx actualizado — mock incluye isProfileLoaded, 2 tests nuevos para isProfileLoaded: false. Total: 586 tests, todos verdes. Build limpio.
2026-07-07 12:27:36 -03:00
1f84997794 Qué se implementó:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
src/features/auth/ConfirmInvitePage.tsx — nuevo componente con tres estados:

idle: pantalla de bienvenida + botón "Confirmar acceso" (estado inicial con params válidos)
loading: botón con spinner mientras se llama a verifyOtp
error: mensaje descriptivo + link a /login (token vacío/incorrecto en params, o fallo en verifyOtp)
El verifyOtp solo se llama al click del usuario — nunca al montar, por eso el pre-scan del email no consume el token.

src/router.tsx — ruta /auth/confirm agregada como pública (PUBLIC_PATHS) + confirmInviteRoute en el árbol de rutas.

tests/features/auth/confirm-invite.test.tsx — 6 tests cubriendo: no-call on mount, error con params vacíos, error con type distinto, llamada correcta a verifyOtp, navegación en éxito, y error con mensaje de detalle.

ResetPasswordPage no requiere cambios — updateUser({ password }) solo necesita sesión activa, que verifyOtp crea en el paso previo.
2026-07-07 11:30:21 -03:00
074f726d62 Ambas funciones deployadas al proyecto inq-roi-desa (xgqbkxcliyjzisoccsur).
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 11:02:27 -03:00
6be9f6267f Pre-Cierre sprint 7.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 10:44:23 -03:00
ef9ec472d5 Etapa 5 completa.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 10:32:02 -03:00
bc9f70419c Resumen de lo entregado
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 09:47:31 -03:00
2922d4c2f3 docs(sprint-6): cierre formal — observaciones Etapas 1-3 + reflexiones estratégicas
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-06 18:52:24 -03:00
b117ad258d fix(sprint-6/etapa-3): localStorage key en ProfileSheet + eliminar setTimeout(350) + specs E2E de auth
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-06 18:51:52 -03:00
b42cdc7e80 fix(sprint-6/etapa-2): mover gráficos antes de ComparisonTable en tab ROI
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
SensitivityPanel y CumulativeSavingsChart deben ser adyacentes: el slider afecta
el gráfico (doble línea) y el usuario no debe scrollear a través de la tabla de
comparación para ver el efecto. Reordenamiento de JSX puro, sin cambios de lógica.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 20:45:24 -03:00
bae8dc11c7
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-24 20:06:23 -03:00
1e4dbc0bed feat(sprint-6/etapa-2): panel de análisis de sensibilidad + doble línea en CumulativeSavingsChart
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-24 16:42:32 -03:00
b9cd57d148 feat(sprint-6/etapa-1): logo InQuality en header PDFs (págs 2+) + whitespace-nowrap eficiencia
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-24 16:22:37 -03:00
dc610f8a1e Resumen del fix:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
loadImageBase64 ahora retorna { base64, pixelWidth, pixelHeight } (320×210px tras el resize) en vez de solo el string.
PdfHeaderOptions agrega logoPixelWidth/logoPixelHeight.
drawSharedHeader: logo a 15mm de ancho con alto calculado proporcionalmente desde las dimensiones reales (≈9.8mm, ratio real ~1.53:1, no el 3.56:1 hardcodeado anterior). El logo se posiciona arriba (MARGIN - 1), y processName + fecha bajan a la línea siguiente sin superponerse. clientName baja una línea adicional cuando hay logo (para no chocar con la fecha), ajustando y antes del border-bottom.
Fallback a texto "InQ ROI" intacto cuando no hay logo.
2026-06-24 12:16:25 -03:00
f47cf82abf chore(sprint-6): abrir sprint — BRIEF + OBSERVATIONS reset
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-23 22:54:48 -03:00
5ca5d7ea31 chore(sprint-5/etapa-6): limpieza técnica — E2E Supabase, fix flakiness guardar, docs cierre sprint
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-23 22:33:01 -03:00
485cc91d73 feat(sprint-5/etapa-5): columnas Ahorro esperado + Eficiencia en ComparisonTable — A/B para focus group
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-23 21:58:40 -03:00
e9073ae732 Único cambio: la celda de "Eficiencia" ahora muestra ${value.toFixed(2)} ${currency}/min.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-23 17:20:30 -03:00
bf200b72f9 Los cambios en reporte de eficiencia
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-23 17:09:54 -03:00
ecfb72c3f1 docs(sprint-5): actualizar OBSERVATIONS/TECH_DEBT/BACKLOG — Etapa 4 documentada, items resueltos marcados, logo PDF corregido
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-23 16:02:47 -03:00
db82be4e8c fix(lint): ReportPage.tsx — useMemo dep simulation en lugar de simulation?.result + eslint-disable intencional en useEffect
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-23 18:47:35 +00:00
c93d8e824f fix(sprint-5/etapa-4h-2): observar prop open en vez de onOpenChange — fix anterior no se disparaba
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
El fix anterior (1aa1486) interceptaba onOpenChange de SheetPrimitive.Root, pero ese
callback solo se dispara cuando RADIX decide cerrar internamente (Escape, overlay,
botón X). Cuando el padre cierra el Sheet cambiando el prop `open` directamente —que
es lo que hace ProfileSheet.handleSave(): llama onOpenChange(false), que es
setProfileOpen(false) del padre AppHeader— Radix nunca invoca su propio onOpenChange.
El wrapper nunca se ejecutaba para este caso, exactamente el que reportó el usuario
en producción (body queda con pointer-events:none stuck tras guardar perfil).

Fix: useEffect que observa el prop `open` en sí, cubre ambos canales de cierre
(interno de Radix y externo del padre) sin depender de qué disparó el cambio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 10:01:57 -03:00
1aa1486d88 fix(sprint-5/etapa-4h): safety net en Sheet — body queda con pointer-events:none stuck tras cerrar
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Bug de Radix/react-remove-scroll: el cleanup que restaura pointer-events del body
al cerrar un Dialog/Sheet puede no ejecutarse (race con re-renders externos durante
la animación de salida). Resultado: toda la página queda no interactiva hasta
refrescar manualmente — confirmado en producción tras guardar perfil pese al
defer de 350ms ya aplicado en ProfileSheet (mitigaba una causa, no era estructural).

Fix a nivel del primitivo Sheet (cubre todos los usos, no solo ProfileSheet):
al cerrar, espera 400ms (> 300ms de la animación) y si no queda ningún
[role="dialog"][data-state="open"] en el DOM pero el body sigue con
pointer-events:none, lo limpia explícitamente.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:53:14 -03:00
aaf1f79519 fix(sprint-5/etapa-4g): defer updateUser 350ms — espera fin de animacion Radix antes de re-render AuthContext 2026-06-23 10:17:47 +00:00
985c655f6a fix(sprint-5/etapa-4g): defer updateUser — evita overlay Radix stuck en DOM
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-20 20:26:47 -03:00
a1a8b1c651 fix(sprint-5/etapa-4g): corregir clave localStorage en getAccessToken — supabase.auth.token
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-20 23:15:29 +00:00
7da39c7f1e prompt
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-20 20:08:23 -03:00
7f1f83382d fix(sprint-5/etapa-4g): revert ProfileSheet a raw fetch — evita processLock contention
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
supabase.from().update() adquiere processLock internamente via getSession(), bloqueando
otras queries Supabase mientras espera. Raw fetch con token de localStorage evita el
lock completamente. Mantiene el patrón optimista: updateUser + onOpenChange(false)
antes del fetch, que corre en background fire-and-forget con AbortSignal.timeout(10s).

getAccessToken() ahora deriva el projectRef desde el hostname de SUPABASE_URL en lugar
de buscar con startsWith/endsWith — más robusto y sin iteración de todas las keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 20:06:51 -03:00
1a4650d186 a ver si ahora sí
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-20 19:56:02 -03:00
69f744ae3f fix(sprint-5): optimistic update en ProfileSheet — cierra Sheet antes del fetch, UI nunca bloqueada
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-20 19:35:04 -03:00
27ce96ca1e fix(sprint-5): AbortSignal.timeout(8s) en fetch de ProfileSheet — evita freeze por modal bloqueada
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-20 22:19:20 +00:00
882edd230d a ver si ahora sí guarda el perfil
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-20 19:06:58 -03:00
1c5329915a fix(sprint-5/etapa-4f): || fallback en name vacío — botón Guardar habilitado cuando Google devuelve full_name: ""
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
El operador ?? solo hace fallback con null/undefined, no con cadena vacía.
Si Google devuelve user_metadata.full_name: "", la cadena de propagación es:
  buildAuthUser → name=""  →  upsertUserOnLogin inserta ""  →  syncProfileFromDB
  devuelve ""  →  AuthContext setUser → "" ?? prev.name = ""  →  ProfileSheet
  setName("") → name.trim() === '' → botón disabled → click no hace nada.

Fix: cambiar ?? por || en todos los puntos donde full_name o name de DB
se propagan al estado: buildAuthUser, syncProfileFromDB, getUserFromStorage,
useEffect IIFE en AuthContext. Agregar fallback a email en ProfileSheet
setName() para usuarios cuya fila en DB ya tiene name="" de sesiones anteriores.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 19:06:30 -03:00
3ebfd65a90 fix(sprint-5/etapa-4e): lockAcquireTimeout 30s + eliminar getSessionSafe — corta freeze en guardar perfil
getSessionSafe() tenía un problema de diseño: Promise.race([getSession(), timeout])
abandona la Promise de getSession() cuando gana el timeout. La Promise abandonada
deja una operación en PROCESS_LOCKS[name] que bloquea el chain. Con lockAcquireTimeout=5s
(default), un token refresh lento dispara ProcessLockAcquireTimeoutError cuyo handler
hace await previousOperation (colgada) → freeze permanente.

Fix: lockAcquireTimeout: 30000 (margen para redes lentas) + eliminar getSessionSafe()
de supabase.ts, library-store, process-store y useReportData. Las queries Supabase
llaman getSession() internamente vía _getAccessToken() que espera initializePromise
automáticamente — la barrera era redundante.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 19:05:23 -03:00
8142b07525 a ver si ahora sí
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-20 02:26:33 -03:00
602957b6c9 fix(sprint-5/etapa-4b): timeout AbortController en sync de perfil al login
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
upsertUserOnLogin y syncProfileFromDB adquieren el processLock de Supabase para
leer/escribir la sesión. Si el servidor GoTrue está lento (token refresh), ese
lock puede quedar ocupado varios segundos bloqueando loads de biblioteca/workspace.

Agrega AbortController con timeout de 6s a las dos queries de auth sync en
AuthContext. Si el servidor no responde a tiempo, la señal de abort cancela
el fetch, postgrest-js lanza AbortError, el lock se libera, y el IIFE termina
silenciosamente. El profile save ya usa fetch nativo (no le afecta el lock).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 02:13:53 -03:00
3d3ae99125 fix(sprint-5/etapa-4b): save perfil via fetch nativo — bypasea processLock compartido
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
upsertUserOnLogin + syncProfileFromDB (AuthContext useEffect) adquieren el
processLock de supabase-js al cargar la página. Si el servidor tarda, ese lock
queda ocupado y supabase.from('users').update() en handleSave espera para siempre
en la misma cola — congelando la UI indefinidamente.

Fix: reemplazar el cliente Supabase por fetch nativo en ProfileSheet. El token
se lee directamente de localStorage (donde GoTrueClient lo escribe tras cada
login/refresh), eliminando toda dependencia del processLock. Si el token no
está disponible o el servidor responde con error, se muestra el mensaje inline
en lugar de colgar el botón.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 02:08:25 -03:00
a626ff040c fix de guardar perfil - ahora si
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-20 01:31:02 -03:00
496c227c88 fix(sprint-5/etapa-4d): mover queries DB fuera de onAuthStateChange — corta deadlock lockAcquired permanente
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
El callback de onAuthStateChange en GoTrueClient adquiere un lock interno (lockAcquired=true).
Hacer await supabase.from('users').select() dentro de ese callback genera un deadlock circular:
el select necesita getSession() → _acquireLock() → lockAcquired=true → espera al callback →
callback espera al select → deadlock permanente. Toda query posterior a Supabase cuelga.

Fix: el callback de onAuthStateChange pasa a ser síncrono y solo llama buildAuthUser() (sin
queries). El upsert y la lectura de company/name se mueven a un useEffect([user?.id]) en
AuthContext, que corre FUERA del lock de auth y solo se dispara al cambiar de usuario real
(distinto id), no en cada updateUser() local.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 01:28:26 -03:00
dd361b1b12 fix(sprint-5/etapa-4b): library useEffect usa user.id no user ref — corta contención processLock en guardar perfil
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 00:57:16 -03:00
224b3bc5e7 debug(sprint-5/etapa-4b): logs temporales en ProfileSheet para diagnosticar loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 00:52:21 -03:00
4a19eda1b2 fix guardar perfil
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-20 00:39:39 -03:00
735294db06 fix(sprint-5/etapa-4b): prevenir loop en onAuthStateChange al guardar perfil
- SupabaseAuthService: solo leer company/name de DB en INITIAL_SESSION y SIGNED_IN;
  ignorar TOKEN_REFRESHED y USER_UPDATED para no sobreescribir ediciones locales
- ProfileSheet: useEffect con solo [open] como dep — inicializa el form al abrir
  el sheet, sin resetearlo en cada cambio del objeto user durante la edición

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 00:35:01 -03:00
7ffab83caf Feature A — Procesos por recurso (/recursos)
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-20 00:07:46 -03:00
6d14e64f33 feat(sprint-5/etapa-4): procesos por recurso + campo empresa en perfil
Feature A — Ver procesos asociados a un recurso:
- resource-repo.ts: getProcessCountsPerResource() (una query, N+1 evitado)
- resource-repo.ts: getProcessesForResource() (on-demand al expandir)
- ResourceCatalogPage: carga conteos en paralelo, columna "Procesos" con
  badge naranja clicable, fila expandida con links a /workspace/:id

Feature B — Campo empresa en perfil:
- 013_add_company_to_users.sql: ALTER TABLE users ADD COLUMN IF NOT EXISTS company text
- AuthUser: agrega company? opcional
- SupabaseAuthService: lee company y name de tabla users en onAuthStateChange
- AuthContext: agrega updateUser() para reflejar cambios sin recargar
- ProfileSheet.tsx: Sheet con nombre/empresa editables, email read-only
- AppHeader: muestra empresa en dropdown + item "Editar perfil"

528 tests Vitest verdes. Build limpio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 19:56:13 -03:00
e8a4a1b3e0 fix(sprint-5/etapa-3b): verificación de 3 fixes post-validación visual
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Los tres fixes descritos en ETAPA_3B_PROMPT.md se verificaron como ya
implementados en el commit de Etapa 3 (a65bec4):

- Fix 1 (repintado canvas): mode en deps de useEffect en HeatmapCanvas.tsx
  existía desde el MVP — bug observado en estado intermedio de Etapa 3.
- Fix 2 (localStorage): useHeatmapMode() con lazy initializer + conectado
  en ReportPage.tsx.
- Fix 3 (zoom position): bottom left: 12 en BpmnCanvas.tsx.

528 tests Vitest verdes. Build limpio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 18:52:18 -03:00
a65bec45c3 feat(sprint-5/etapa-3): heatmap gradiente 4 stops + modo percentil + localStorage + fix zoom
- colors.ts: gradiente continuo verde→amarillo→naranja→rojo (interpolación N-stops genérica)
- Renombra modos: relative→percentile (rank-based), absolute→minmax
- Hook useHeatmapMode(): persiste selección en localStorage con clave inq-roi:heatmap-mode
- HeatmapLegend: barra de 4 stops, labels P0/P50/P100 en modo percentil
- HeatmapModeToggle: etiquetas "Percentil" / "Min-max"
- pdf-sections.ts: leyenda PDF con 4 stops interpolados
- BpmnCanvas: controles de zoom movidos a esquina inferior izquierda (evita logo bpmn.io)
- Tests: 528 unitarios pasan; E2E recalibrados con vecino más cercano (distancia euclidiana)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 18:28:10 -03:00
889929329e Sprint 5 — Etapa 2: resumen
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Fix 0 (skeleton carry-over): el guard de Etapa 1 era isLoading && !currentProcess — fallaba al navegar directo entre dos workspaces distintos, porque el proceso anterior seguía en el store mientras el nuevo cargaba. Cambié a if (isLoading) return <WorkspaceSkeleton /> sin condición adicional, exactamente como pedía el brief.

Fix 1 (fit-viewport): una línea — el botón llamaba canvas.zoom('fit-viewport') sin 'auto' (la carga inicial sí lo tenía). Agregado.

Fix 2 (controles de zoom): agregué ZoomIn/ZoomOut de Lucide (no Tabler — ya está en el stack del proyecto, son visualmente equivalentes) en la esquina inferior derecha, apilados verticalmente, mismo estilo que el botón de encuadre (32×32px, fondo blanco semitransparente). Factor 1.2x, límites 0.1x–4x tal como especificaba el brief.

Verificación:

Build limpio
Lint: 2 warnings any nuevos en BpmnCanvas.tsx, consistentes con las 9 que ya existían en ese mismo archivo (el propio snippet del brief los prescribe así; bpmn-js tipa débilmente viewer.get()) — no es una categoría nueva de problema, lo marco para que quede explícito
Vitest: 521/521 verdes
E2E smoke: 9/9 verdes
Falta tu validación visual — en particular probar con Slow 3G que aparece el skeleton (no el spinner), y confirmar que el encuadre centra el diagrama y los botones de zoom no tapan nada.
2026-06-19 15:46:08 -03:00
a2763ab755 Sprint 5. Etapa 1.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-19 15:31:03 -03:00
84daf82e65 Sprint 4 — Etapa 8: reporte de entrega
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Tier 1 — Automatizado
Build:  limpio
Lint:  limpio en archivos nuevos de Sprint 4 (separé resource-types.ts de resource-display.tsx para sacar 2 warnings de Fast Refresh; quedan 0 errores/warnings nuevos). Quedó 1 error preexistente en ReportPage.tsx (no tocado desde Sprint 3, fuera de scope) y ~102 warnings no-explicit-any preexistentes en tests viejos.
Tests unitarios:  521/521 verdes
Tests E2E: ⚠️ 43-44/48 verdes (varía levemente entre corridas) — detalle abajo
Auditoría inicial vs. estado final
Antes: 39/39 specs (100%) fallaban — ROJO-AUTH puro, todos por #bpmn-file-input nunca apareciendo (esperaban el flujo pre-Sprint-4 directamente en /).

Decisión tomada (con tu input): Opción A — sesión real. Creaste el usuario martin.bernal@inquality.com.py; yo verifiqué que no tenía fila en public.users y se la inserté con platform_role='platform_admin'. Implementé tests/e2e/setup/auth-setup.ts como globalSetup de Playwright: hace signInWithPassword, inyecta la sesión real en localStorage con el formato exacto que usa supabase-js v2, y genera tests/e2e/setup/auth-state.json (gitignored) en cada corrida.

Fix adicional necesario: todos los specs navegaban a / esperando el input de archivo ahí — ahora vive en /import. Corregí los 18 page.goto('/') → /import, incluyendo reescribir el flujo obsoleto de prod-smoke.spec.ts (esperaba una landing pre-auth que ya no existe).

Resultado: 33→43-44 specs pasan. Quedan 5-6 ROJO-OTRO, con dos causas raíz identificadas (no relacionadas con auth, fuera del mandato de esta etapa que pide no reescribir lógica de tests):

Botón "Guardar" en ActivityPanel queda deshabilitado (3 specs de PDF) — encontré algo extraño: el texto del botón dice "Guardar cambios" (que solo se renderiza si isDirty=true) pero Playwright lo sigue viendo disabled. Esto contradice la lógica simple de disabled={!isDirty} en el componente — sospecho un bug real de re-render en producción, pero diagnosticarlo a fondo requiere debugging en vivo. Recomiendo abrir esto como bug dedicado en un sprint futuro.
2 specs usan IndexedDB/Dexie directamente (export-pdf.spec.ts, validate-dod-etapa4.spec.ts) — Dexie se eliminó completamente en Etapa 4. Estos tests necesitan reescritura para inyectar datos vía Supabase en vez de IndexedDB — eso sí es "reescribir lógica de tests", explícitamente fuera de este alcance.
Los resultados varían levemente entre corridas porque Playwright corre specs en paralelo bajo la misma cuenta de test compartida — puede haber interferencia de datos entre specs concurrentes. Sugiero como mejora futura (no implementada ahora): --workers=1 para diagnóstico determinístico.

Entregables nuevos
tests/e2e/setup/auth-setup.ts — global setup con sesión real
tests/e2e/sprint-4-smoke.spec.ts — 9 tests (4 sin auth: login, redirects de /, /workspace, /recursos; 5 con auth: biblioteca sin 500, sin 406 en simulations, catálogo carga, avatar visible, import navega a workspace)
docs/CHECKLIST_ENTREGA.md — las 4 tiers completas
.env.test (gitignored) + .env.example documentado
.gitignore actualizado (.env.test, auth-state.json)
Polish
Textos de OwnershipBadge y "Compartir con equipo" ya estaban en español — sin cambios.
Tooltip "Disponible próximamente" para "Compartir con equipo": no lo agregué, tal como pedía el propio brief, consultarte antes. ¿Lo querés?
2026-06-19 11:22:50 -03:00
f77085c42d TooltipProvider agregado en App.tsx envolviendo todo el contenido (dentro de AuthProvider, fuera de RouterProvider/Toaster), sin tocar nada más.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-19 08:12:55 -03:00
92597e108d Etapa 7B completa con Fix 4 agregado:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Fix 4 Parte A: .maybeSingle() ya estaba aplicado (lo hiciste vos/el linter). Encontré y arreglé un bug adicional: con data === null (sin simulaciones), el código llamaba fromRow(null) y explotaba al leer row.id. Ahora getLatestByProcess retorna undefined limpiamente cuando no hay filas.
Fix 4 Parte B: revisé ProcessCardWithSim en LibraryPage.tsx — ya tiene guard de cancelación y dependencias estables (process.id + acción de Zustand, que es una referencia fija). No encontré un loop real; lo más probable es que "~6 queries" correspondan a 6 procesos distintos disparando una query cada uno (correcto), posiblemente duplicado visualmente en dev por StrictMode (inocuo, no pasa en producción).
Fixes 1, 2, 3 ya estaban implementados de antes y siguen verdes. Falta tu validación visual — en particular confirmar que el network tab ya no muestra 406 en simulations.
2026-06-19 00:33:46 -03:00
c738bc5489 Sprint 4 — Etapa 7B completada.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-19 00:20:27 -03:00
892b8b3bc7 Sprint 4 — Etapa 7 completada. Build limpio, 521/521 tests verdes. Resumen:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Tipos: Resource.processId? eliminado (vestigial); ResourceType ya tenía los 5 valores correctos, sin cambios.

resource-repo.ts: agregado getAllWithCreator() con JOIN a users!created_by (mismo patrón que Etapa 6) y tipo ResourceWithCreator. delete() ahora valida que no haya asignaciones activas en activity_resource_assignments antes de borrar — lanza error explicativo si las hay. Verifiqué ambos contra Supabase real: el JOIN funciona (FK reconocida por PostgREST) y el conteo de guarda también.

Nuevo — src/features/resources/:

resource-display.tsx — labels, colores/íconos por tipo (Lucide: User/Briefcase/Server/Wrench/Package) y ResourceTypeAvatar compartido
ResourceFormSheet.tsx — Sheet reutilizable para crear/editar, usado tanto en el catálogo como en el panel del workspace
ResourceCatalogPage.tsx — página /recursos: búsqueda, filtro por tipo, tabla con badge de tipo, costo, "creado por", editar/eliminar con confirmación
Nuevo — src/components/ui/sheet.tsx: componente shadcn Sheet (no existía), construido sobre @radix-ui/react-dialog que ya estaba instalado — no agregué dependencias nuevas.

ResourcesPanel.tsx rediseñado: ahora solo muestra recursos usados en el proceso actual (derivado de assignedResources), con conteo de actividades por recurso. CRUD completo se mudó a /recursos; el panel solo permite "Nuevo" (catálogo global) y tiene el link "Ver catálogo completo".

Router: ruta /recursos registrada.

ActivityPanel no se tocó, como indicaba el scope.
2026-06-19 00:01:46 -03:00
4f51c74717 auth con avatar
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-18 22:31:22 -03:00
5141a5c06c Este es el workaround documentado oficialmente para el bug conocido de navigator.locks colgándose en supabase-js v2
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-18 22:06:53 -03:00
c20624399c capa adicional sobre lo que ya deployaste
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-18 21:54:44 -03:00
b3b6fbe792 dummy change
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-18 21:46:44 -03:00
81a9424f7f Solución del login colgado
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-06-18 21:33:48 -03:00
0b07fffad9 feat(admin): filtro por empresa + contadores filtrados 2026-06-17 10:01:10 -03:00
d426791a53 feat(sprint-4/etapa-5): modelo de privacidad RLS granular + UI de visibilidad
- SQL 010: tablas user_groups y group_memberships con RLS
- SQL 011: tabla process_access para acceso explícito a procesos
- SQL 012: función is_platform_admin() + políticas granulares (select/insert/update/delete) en processes
- types.ts: Process.ownerId (requerido)
- process-repo: fromRow mapea owner_id→ownerId; toRow preserva ownerId en updates; setShared() no-op documentado
- ImportPage: asigna ownerId: user.id al crear proceso
- LibraryPage: toggle Todos/Míos, OwnershipBadge (🔒/👤) en cada tarjeta, opción "Compartir con equipo" en menú de proceso propio con toast de confirmación
- Tests: ownerId: 'test-user' agregado a todos los fixtures de Process

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

View File

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

6
.dockerignore Normal file
View File

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

View File

@@ -2,3 +2,17 @@
# Obtener desde Supabase Dashboard → Project Settings → API # Obtener desde Supabase Dashboard → Project Settings → API
VITE_SUPABASE_URL=https://<project-ref>.supabase.co VITE_SUPABASE_URL=https://<project-ref>.supabase.co
VITE_SUPABASE_ANON_KEY=<anon-public-key> VITE_SUPABASE_ANON_KEY=<anon-public-key>
# Usuario de test para E2E (email/password, NO Google OAuth) — crear en
# Supabase Studio → Authentication, y darle platform_role='platform_admin'
# en la tabla public.users. Va en .env.test (gitignored), no en .env.local.
TEST_USER_EMAIL=test@inquality.com.py
TEST_USER_PASSWORD=<password-del-usuario-de-test>
# service_role_key para inyección directa de datos en specs E2E (export-pdf.spec.ts,
# validate-dod-etapa4.spec.ts) — bypasa RLS para escribir costos/configuración de
# actividades sin pasar por la UI. Obtener desde Supabase Dashboard → Project Settings
# → API → service_role (secret). Va en .env.test (gitignored), NUNCA en .env.local
# ni en código que se sirva al browser. Si no está presente, esos specs hacen skip
# explícito con mensaje descriptivo en lugar de fallar.
SUPABASE_SERVICE_ROLE_KEY=<service_role_key>

2
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env.test
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
@@ -26,6 +27,7 @@ dist-ssr
# Playwright outputs # Playwright outputs
tests/e2e/__output__/ tests/e2e/__output__/
test-results/ test-results/
tests/e2e/setup/auth-state.json
# Wrangler # Wrangler
.wrangler/ .wrangler/

View File

@@ -17,11 +17,17 @@ Cada ítem tiene una etiqueta de estado:
Al cerrar un sprint, los ítems `[SPRINT-N]` se promocionan a `[SPRINT-(N+1)]` o vuelven a `[BACKLOG]`. Al cerrar un sprint, los ítems `[SPRINT-N]` se promocionan a `[SPRINT-(N+1)]` o vuelven a `[BACKLOG]`.
--- ---
## Features de alta prioridad ## Features de alta prioridad
### [BACKLOG] Indicador de simulación desactualizada ### [RESOLVED] Ver procesos asociados a un recurso + navegar a ellos
Cuando el usuario guarda cambios en una actividad (costos, recursos, automatización) pero no vuelve a simular, el reporte muestra resultados del estado anterior sin ningún aviso. Agregar un indicador sutil ("Hay cambios sin simular" o badge en el botón Simular) que desaparezca al ejecutar la simulación. Detectado en uso real durante Sprint 2 — genera confusión al no ver los nuevos costos reflejados en el reporte. Implementado en Sprint 5 Etapa 4. `ResourceCatalogPage` muestra columna "Procesos" con conteo. Click expande lista de procesos con links. `resource-repo.ts` provee `getProcessCountsByResourceId()` y `getProcessesByResourceId()`. Confirmado 2026-06-23.
### [RESOLVED] Campo "empresa" en perfil de usuario
Implementado en Sprint 5 Etapa 4. Columna `company` en tabla `users` (Supabase), campo editable en `ProfileSheet`, visible en dropdown `AppHeader`. Confirmado 2026-06-23.
### [RESOLVED] Indicador de simulación desactualizada
Implementado en Sprint 3. Confirmado funcionando en validación visual Sprint 4 (muestra "Hay cambios sin simular" junto al botón Simular). Cerrado Sprint 5 planning (2026-06-19).
### [BACKLOG] Múltiples simulaciones por proceso ### [BACKLOG] Múltiples simulaciones por proceso
Historial de simulaciones con comparación de escenarios. Historial de simulaciones con comparación de escenarios.
@@ -29,8 +35,8 @@ Historial de simulaciones con comparación de escenarios.
### [BACKLOG] Recursos con costo por hora ### [BACKLOG] Recursos con costo por hora
Link actividad ↔ recurso (rol, persona, sistema) con cálculo automático del costo agregado. Link actividad ↔ recurso (rol, persona, sistema) con cálculo automático del costo agregado.
### [BACKLOG] Edición del nombre de proceso y cliente desde el workspace ### [RESOLVED] Edición del nombre de proceso y cliente desde el workspace
Hoy requiere ir a configuración global. Mover edición inline al workspace. Implementado en Sprint 2. Confirmado funcionando en validación visual Sprint 4. Cerrado Sprint 5 planning (2026-06-19).
### [BACKLOG] Gateway OR inclusivo nativo ### [BACKLOG] Gateway OR inclusivo nativo
Actualmente se simula como XOR. Implementar propagación OR real con probabilidades independientes. Actualmente se simula como XOR. Implementar propagación OR real con probabilidades independientes.
@@ -57,8 +63,8 @@ Manifest + service worker para uso offline.
### [BACKLOG] Zoom/pan del heatmap desde el reporte ### [BACKLOG] Zoom/pan del heatmap desde el reporte
Hoy es visualización estática. Permitir interacción. Hoy es visualización estática. Permitir interacción.
### [BACKLOG] Controles de zoom en canvas BPMN (fit-to-screen / reset zoom) ### [RESOLVED] Controles de zoom en canvas BPMN (fit-to-screen / zoom-in / zoom-out)
El canvas puede quedar muy zoomed out sin forma de volver al estado inicial. bpmn-js expone `canvas.zoom('fit-viewport')` — agregar botón "Encuadrar diagrama" o equivalente. Detectado en validación visual Sprint 2 Etapa 3. Implementado en Sprint 5 Etapa 2. fit-viewport con centering correcto, botones zoom-in/zoom-out/fit en esquina inferior izquierda (1.2x factor, límites 0.1x4x). OK formal del director 2026-06-19.
### [BACKLOG] Detalle de recursos por actividad en el PDF ### [BACKLOG] Detalle de recursos por actividad en el PDF
Tabla de costos por recurso asignado dentro de cada actividad. Tabla de costos por recurso asignado dentro de cada actividad.
@@ -66,15 +72,19 @@ Tabla de costos por recurso asignado dentro de cada actividad.
### [BACKLOG] Importación por URL ### [BACKLOG] Importación por URL
Cargar BPMN desde enlace directo (GitHub, Confluence, etc.). Cargar BPMN desde enlace directo (GitHub, Confluence, etc.).
### [BACKLOG] Columna "Eficiencia de automatización" en ComparisonTable ### [RESOLVED] Columna "Eficiencia de automatización" en ComparisonTable
Métrica = `(ahorro$ × probabilidad) / inversión-prorrateada`. Permite rankear actividades por "qué tan valiosa es automatizar cada una". Implementado en Sprint 5 Etapa 5. Dos columnas: "Ahorro esperado" (`ahorro$ × executionProbability`) y "Eficiencia" (`ahorro$ × executionProbability / executionTimeMinutes` en `currency/min`). Barras de progreso proporcionales, sort multi-columna, tooltips. A/B para focus group con clientes. OK formal del director 2026-06-23.
### [BACKLOG] ComparisonTable — whitespace-nowrap en celda de Eficiencia
El valor y la unidad ("1.02 USD/min") se cortan en dos líneas por ancho de celda.
**Fix:** agregar `whitespace-nowrap` al `<span>` del número en `MetricCell` dentro de `ComparisonTable.tsx`. Una línea de cambio.
--- ---
## Features Fase 2 (versión 2.0 del producto) ## Features Fase 2 (versión 2.0 del producto)
### [FASE-2] Login y multi-tenant ### [RESOLVED] Login y multi-tenant
Persistencia en la nube, colaboración entre múltiples usuarios. Implementado en Sprint 4. Supabase + Google Workspace OAuth, PostgreSQL, RLS, catálogo de recursos global, identidad en UI. Ver `sprints/sprint-4/BRIEF.md`. Cerrado 2026-05-xx.
### [FASE-2] Versionado de procesos ### [FASE-2] Versionado de procesos
Historial de cambios con diff visual. Historial de cambios con diff visual.
@@ -109,5 +119,44 @@ Mantener en web app o eliminar cuando InQuality venda a clientes internacionales
**Decisión pendiente para:** Sprint 3-4 con feedback de uso real en talleres y demos. **Decisión pendiente para:** Sprint 3-4 con feedback de uso real en talleres y demos.
### [DEFERRED] Ícono de InQuality en header de PDFs ### [BACKLOG] Logo InQuality en header de PDFs
Requiere assets de marca consolidados que aún no existen. Re-evaluar cuando InQuality tenga manual de marca completo. Los assets SÍ existen en `assets/` (4 PNGs documentados en `simulador-web/CLAUDE.md`). La justificación original ("aún no existen") era incorrecta.
**Assets disponibles:**
- `MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png` — logo horizontal color (candidato principal)
- `MARCA FINAL 2023 AREA DE SEGURIDAD 1 BCO.png` — logo horizontal blanco (para fondos oscuros)
**Trabajo pendiente:** cargar el PNG vía `html2canvas` o convertirlo a base64 e inyectarlo en el header de los 3 PDFs (Actual, Automatizado, ROI). Ya existe la regla en CLAUDE.md de usar el PNG en lugar de texto "InQ"/"InQuality".
**Estimación:** Sprint 6 o posterior. Baja prioridad — los PDFs ya tienen "Powered by InQuality" en footer.
### [SPRINT-8] Consolidación modelo de cliente: organizations → areas → procesos
Refactorización de modelo de datos para unificar los tres conceptos paralelos de "cliente" (process_groups, clientName, org_id) en una jerarquía de dos niveles.
**Scope:**
- Nueva tabla `areas (id, org_id, name)` — reemplaza `process_groups`
- `area_id` en `processes` — reemplaza `group_id`
- Eliminar campo `clientName` (texto libre) del dominio y la UI
- `LibraryPage`: agrupa por org → área (en lugar de por clientName)
- Workspace sidebar: org como contexto (read-only), área como selector normalizado
- Admin UI: CRUD de áreas por org
- RLS para `areas`
- Migración: bulk-assign 154 procesos de prueba a InQuality, eliminar process_groups de prueba
- Tests E2E del flujo completo
**Referencia:** `STRATEGIC_DIRECTION.md` → "Consolidación del modelo de cliente" (2026-07-07).
---
### [BACKLOG] Ocultar Settings y ruta /settings para roles de cliente
El ícono de tuerca (⚙) en LibraryPage es visible y navegable para `client_editor` y `client_viewer`. La página de configuración ("Etiqueta de grupos") es una feature interna de InQuality — un cliente no debería ver ni editar esta preferencia.
**Trabajo pendiente:**
- Ocultar el botón de Settings en LibraryPage para `client_editor` y `client_viewer`
- Agregar guard en la ruta `/settings` (componente-level, mismo patrón que `/admin/*` y `/import`)
- Verificar si `/recursos` también debe ser inaccesible para roles de cliente
**No es riesgo de seguridad** (es solo preferencia de UI local, sin datos sensibles).
**Estimación:** 1h — agregar al sprint siguiente junto con otros ajustes de UX de cliente.

View File

@@ -1,262 +1,129 @@
# Changelog # Changelog
Historial de cambios del proyecto. Formato basado en [Keep a Changelog](https://keepachangelog.com/es/). All notable changes to InQ ROI will be documented in this file.
Los cambios se describen desde la perspectiva del código: qué archivos cambiaron,
por qué, y qué decisiones de diseño están implícitas — para que futuras The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
modificaciones profundas tengan contexto suficiente. and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ## [Unreleased]
### Added
## [Unreleased] — Sprint 1: ROI y Escenarios de Automatización - `RequireRole` componente wrapper para guards de rol declarativos (Approach B — sin router context) (Sprint 8)
- Edge Function `delete-user`: hard delete permanente de usuario en auth.users + public.users (Sprint 8)
### Estado - Edge Function `list-users`: lista usuarios con email desde auth.users vía service_role (Sprint 8)
En construcción. Etapas 14 completas; Etapas 56 pendientes. - UsersPage: columna "Email" entre Nombre y Rol (via Edge Function list-users) (Sprint 8)
- Migración 018: policy INSERT en `processes` extendida para `client_editor` en su org (Sprint 8)
### Etapa 1 — Cimientos del dominio (completada) - ImportPage: `client_editor` puede importar BPMN; proceso se crea con `orgId` de su cuenta (Sprint 8)
- LibraryPage: botón "Importar BPMN" visible para `client_editor`; empty state con call-to-action de importar (Sprint 8)
**Nuevos campos en el modelo de dominio** (`src/domain/types.ts`):
### Changed
- `Activity` gana: `automatable: boolean`, `automatedCostFixed: number`, `automatedTimeMinutes: number` - AdminLayout: guard useEffect + spinner → RequireRole wrapper (sin flash de contenido protegido) (Sprint 8)
- `Process` gana: `annualFrequency: number`, `analysisHorizonYears: number`, `automationInvestment: number` - ImportPage: guard useEffect → RequireRole wrapper (Sprint 8)
- Nuevo tipo `Scenario = 'actual' | 'automated'` - UsersPage: "Eliminar" (soft delete / revoke-user) → "Eliminar permanentemente" (hard delete / delete-user) (Sprint 8)
- `SimulationInput` acepta `scenario?: Scenario` (opcional, default `'actual'` para retrocompatibilidad)
- `Simulation` gana `resultAutomated?: SimulationResult` (opcional, para retrocompatibilidad con snapshots viejos) ### Fixed
- ImportPage: texto de marca corregido — "Process Cost Platform · MVP Fase 0" → "InQ ROI" (Sprint 8)
**Migración Dexie v1 → v2** (`src/persistence/db.ts`): - ImportPage: párrafo "Sin backend · Sin registro · Datos guardados localmente" eliminado (obsoleto desde Sprint 4) (Sprint 8)
- OrganizationsPage: InQuality visible en lista con badge naranja — quitar filtro is_provider (Sprint 8)
- `version(2).upgrade()` popula defaults en registros existentes: - WorkspacePage: back button pasa ?org=orgId para scroll contextual al volver a biblioteca (Sprint 8)
- Activities: `automatable=false`, `automatedCostFixed=0`, `automatedTimeMinutes=0` - LibraryPage: scroll automático a sección de org al recibir ?org param desde workspace (Sprint 8)
- Processes: `annualFrequency=1000`, `analysisHorizonYears=3`, `automationInvestment=0` - router.tsx: redirect de settingsRoute pasa search: { org: undefined } para compatibilidad con validateSearch (Sprint 8)
- Sin cambio de índices — solo nuevos campos con defaults seguros.
### Added (Etapas 1-4)
**Nuevo módulo de ROI** (`src/domain/roi.ts`): - GlobalSettingsPanel: selector de área reemplaza campo libre "Cliente" (Sprint 8)
- WorkspacePage topbar: display de `areaName` de solo lectura reemplaza edición inline de cliente (Sprint 8)
- Función pura `calculateRoi(input: RoiInput): RoiResult` - OrganizationDetailPage: tercer tab "Áreas" con CRUD completo — crear, renombrar, eliminar con confirmación (Sprint 8)
- Fórmulas nominales simples (sin VPN ni TIR — decisión de diseño ADR-014) - LibraryPage: agrupación org → área con headers colapsables para admin (Sprint 8)
- Edge cases explicitados: inversión cero, ahorro negativo, costo actual cero (evitar div/0) - Settings ⚙ oculto para roles cliente + guard beforeLoad en /settings (Sprint 8)
- `paybackMonths = Infinity` cuando no hay ahorro, `roiAnnualPercent = Infinity` cuando inversión=0 y ahorro>0 - Interface `Area` en domain/types.ts (Sprint 8)
- Campos `areaId`/`areaName` en `Process` (Sprint 8)
**Motor de simulación dual** (`src/domain/simulation.ts`): - Repositorio `supabaseAreaRepo` con CRUD completo para tabla `areas` (Sprint 8)
- `fromRow`/`toRow`/`updateEditable`/`getById` en process-repo extendidos con `area_id` (Sprint 8)
- Acepta `scenario` en `SimulationInput`
- En `'automated'`: actividades con `automatable=true` usan `automatedCostFixed`/`automatedTimeMinutes` ## [0.8.0] - 2026-07-07
- Las no-automatable mantienen sus costos originales en ambos escenarios ### Added
- La probabilidad de ejecución NO cambia entre escenarios — solo costos y tiempos - CHANGELOG.md con historial de versiones
- AppFooter con versión visible en todas las páginas
**Simulation store refactorizado** (`src/store/simulation-store.ts`): - Conventional Commits adoptados como estándar de commits
- `result` (single) → `resultActual + resultAutomated + lastSimulatedAt` ## [0.7.0] - 2026-07-07
- Nueva acción `persistResults(processId, actual, automated)`: guarda en Dexie + actualiza memoria en un solo paso atómico ### Added
- Nueva acción `invalidateResults()`: pone ambos a null - Multi-tenancy: tabla `organizations`, 4 roles de plataforma (platform_admin, member, client_editor, client_viewer)
- Suscripción reactiva a `process-store`: cualquier cambio en `activities` o `currentProcess` invalida ambos resultados automáticamente - Edge Functions `invite-user` y `revoke-user` para gestión de acceso
- **Dependencia intencional documentada**: `simulation-store` importa `process-store` en una dirección (sin inversa → sin ciclo) - Panel de administración `/admin` con gestión de Organizations y Users
- Login email/password y página `/reset-password` para flujo de invitación
**useSimulate refactorizado** (`src/features/simulation/useSimulate.ts`): - Back button contextual en workspace (muestra nombre del cliente via JOIN en getById)
- Política RLS `simulations_update` para que client_editor pueda guardar simulaciones
- Corre las dos simulaciones en el mismo ciclo y llama `persistResults` del store (eliminando el import directo de `simulationRepo` desde un hook de feature)
- Retorna `{ resultActual, resultAutomated }` en lugar de un único result ### Fixed
- Race condition en AuthContext: `isProfileLoaded` como guard antes de renderizar contenido rol-dependiente
**Infraestructura ESLint** (`eslint.config.js` — archivo nuevo): - `syncProfileFromDB` ahora lee `platform_role` y `org_id` desde DB (no inferido por dominio de email)
- `normalizeGroupLabel()`: limpia artículos iniciales de labels de grupos con datos corruptos
- Primer config de ESLint del proyecto (antes no existía)
- Regla `no-restricted-imports`: prohíbe importar `persistence/repositories` desde fuera de `store/` ## [0.6.0] - 2026-06-24
- Overrides para `store/**`, `features/import/**`, `features/report/useReportData.ts`, `tests/**` ### Added
- Regla `react-hooks/set-state-in-effect: 'off'`: patrón de sync local state con useEffect es válido en este codebase - Logo InQuality en header de PDFs (isotipo, páginas 2+, fallback a texto)
- **Por qué importa**: si alguien llama `activityRepo.save()` directamente desde una feature sin pasar por el store, los resultados de simulación quedan stale (la suscripción reactiva no dispara) - Panel de análisis de sensibilidad en tab ROI: sliders de volumen e inversión, recálculo en tiempo real
- Doble línea en CumulativeSavingsChart (base gris dashed + ajustada naranja con área sombreada)
**Tests nuevos** (32 tests): - `whitespace-nowrap` en MetricCell de columna Eficiencia
- `tests/domain/roi.test.ts`: 16 casos cubriendo happy path, inversión cero, ahorro negativo, NaN ### Fixed
- `tests/integration/dexie-migration.test.ts`: 6 casos de persistencia de los nuevos campos con valores default y no-default - `ProfileSheet.getAccessToken()` leía clave inexistente (`'supabase.auth.token'`); el guardado de perfil nunca llegaba a Supabase desde Sprint 4. Fix: calcular `sb-${projectRef}-auth-token`
- `tests/store/simulation-store-invalidation.test.ts`: 7 casos de la invariante de invalidación dual
- `tests/integration/sprint1-flow.test.ts`: 11 casos del flujo integrado BPMN → sim actual → sim automated → calculateRoi → KPIs ## [0.5.0] - 2026-06-23
- `tests/domain/simulation.test.ts`: +4 casos de `scenario=automated` con costos en cero, sin NaN, probabilidad invariante ### Added
- `tests/integration/bpmn-collaboration.test.ts`: 3 casos para BPMNs de colaboración con múltiples pools - Fix crítico: workspace loading — race condition auth/processId resuelto con `isProfileLoaded`
- Controles de zoom BPMN: fit-viewport con centering correcto, botones zoom-in/zoom-out/fit
**Archivos de test actualizados** (fixtures con nuevos campos): - Heatmap con gradiente continuo de 4 stops + toggle percentile/min-max persistido
- "Ver procesos por recurso" en catálogo de recursos
- `tests/integration/simulation-engine.test.ts` - Campo "empresa" en perfil de usuario
- `tests/integration/gateway-validation.test.ts` - Columna "Eficiencia de automatización" en ComparisonTable (+ columna "Ahorro esperado")
- `tests/integration/indexeddb-persistence.test.ts`
- `tests/features/report/components.test.tsx` ### Fixed
- `tests/features/report/derivations.test.ts` - Flakiness en botón "Guardar cambios": debounce + Web Locks en process-store
- `tests/lib/export/measure-sizes.test.ts` - Specs E2E migradas de Dexie a Supabase
- `tests/lib/export/csv-export.test.ts`
- `tests/lib/export/pdf-export.test.ts` ## [0.4.0] - 2026-06-18
- `tests/setup.ts`: mocks globales `ResizeObserver` y `PointerEvent` para jsdom (necesario desde Sprint 1 para tests con Slider de Radix) ### Added
- Autenticación Google Workspace OAuth + Supabase
### Etapa 2 — UI de Configuración (completada) - Migración completa IndexedDB (Dexie) → PostgreSQL (Supabase)
- Row Level Security con `is_platform_admin()` y políticas granulares
**Switch component** (`src/components/ui/switch.tsx` — nuevo): - Catálogo global de recursos (sin processId)
- Identidad en UI: avatar/dropdown AppHeader, "Actualizado por" en proceso
- Implementación Tailwind pura sin `@radix-ui/react-switch` - ResourceCatalogPage + ResourceFormSheet
- `role="switch"` + `aria-checked` + `aria-label` para accesibilidad - Fix Web Locks API deadlock (processLock)
- Transiciones CSS: `transition-colors duration-200` en el fondo, `transition-transform duration-200` en el knob - Fix `.maybeSingle()` en simulation-repo
- **Decisión**: no instalar `@radix-ui/react-switch` para no agregar dependencia — el comportamiento visual es idéntico - Fix `formatCostPerHour()` precisión dinámica
- Fix Google OAuth `prompt: 'select_account'`
**ActivityPanel ampliado** (`src/features/workspace/ActivityPanel.tsx`): - Catálogo con contexto de proceso (banner + query param fromProcess)
- Nueva sección "Automatización" al pie del panel, separada con `<Separator>` ## [0.3.0] - 2026-05-27
- Toggle Switch (`automatable`) oculta/muestra los campos con `transition-all duration-200 overflow-hidden max-h-{0|80}` ### Added
- **Limitación conocida**: el max-height trick no anima a la altura real del contenido. Ver TODO.md para mejora con `useRef + scrollHeight` - Biblioteca de procesos con grupos/tags/búsqueda (Dexie v4)
- Inputs numéricos `automatedCostFixed` y `automatedTimeMinutes` con mismo patrón que los existentes (placeholder cuando valor=0) - Indicador de simulación desactualizada ("Hay cambios sin simular")
- Helper ámbar de transparencia metodológica: - Zoom BPMN fit-viewport
- Aparece cuando `automatable=true && cost===0 && time===0` - Recursos TO-BE en escenario automatizado (motor + UI + reporte + PDF)
- **Siempre está en el DOM** (no conditional render) — la visibilidad se controla con `opacity-0/100 + max-h-0/20 + transition-all duration-150` - Branding InQ ROI en todas las páginas (AppHeader + favicon + logos)
- `aria-hidden` refleja el estado de visibilidad - Kit de socialización del equipo: guía de usuario, guía de pre-venta, presentación PPTX
- **Por qué importa**: con conditional render (`&&`) el helper desaparece instantáneamente al tipear — sin transición. Al mantenerlo en el DOM, el fade de 150ms se aplica correctamente
## [0.2.0] - 2026-05-20
**GlobalSettingsPanel ampliado** (`src/features/workspace/GlobalSettingsPanel.tsx`): ### Added
- Modelo de recursos `utilizationMinutes + units` (migración Dexie v3)
- Nueva sección "Análisis de Impacto" al pie del panel - ActivityPanel con cálculo de costo en tiempo real
- `annualFrequency`: input numérico, min=1, sugerencias de referencia en texto helper - Reporte web con filas expandibles de desglose de recursos
- `analysisHorizonYears`: Slider de 1 a 10 años con valor en texto ("3 años") - Edición inline de proceso/cliente
- `automationInvestment`: input numérico, default placeholder vacío cuando valor=0 - Tabla de recursos en PDF (página condicional)
- La validación de rango de `analysisHorizonYears` (clamp 1-10) ocurre en `handleSave`, no en `onChange`
## [0.1.5] - 2026-05-18
**BpmnCanvas ampliado** (`src/features/workspace/BpmnCanvas.tsx`): ### Added
- Identidad visual completa: paleta naranja InQ #F59845, gradiente → magenta #ED3E8F
- Nueva prop `automatableElementIds?: string[]` - PDFs de clase profesional: 3 documentos (Actual, Automatizado, ROI)
- `syncAutomationOverlays()`: callback memoizado con `useCallback([automatableElementIds])` - Badge ⚡ en canvas BPMN para actividades automatizables
- Limpia overlays anteriores con `overlays.remove({ type: 'automation-indicator' })` - ROI con 5 KPIs
- Re-agrega un badge por cada elementId en la lista - Sistema de componentes establecido
- Se llama después de `importXML` (en el `.then()`) y en un `useEffect` separado cuando cambia la lista
- **Posicionamiento del badge**: `{ top: -8, left: element.width - 12 }` ## [0.1.0] - 2026-05-01
- Obtenido via `elementRegistry.get(elementId)` con fallback `width ?? 100` ### Added
- **No usar** `position.right` de diagram-js: con `right: -8`, la fórmula interna `left = right * -1 + width` da `left = 8 + width`, colocando el badge enteramente fuera del nodo sin overlap de esquina - MVP: importar BPMN 2.0, parametrizar actividades, simular costos, exportar PDF
- Con `left = width - 12`: badge left está 12px antes del borde derecho, su borde derecho queda 8px fuera → overlap visible en la esquina - Motor de simulación AS-IS / TO-BE (Modelo A)
- **Badge HTML**: círculo 20×20px, fondo `#fffbeb` (amber-50), borde `#e2e8f0`, sombra `0 1px 3px rgba(0,0,0,0.12)`, ícono Bot Lucide en `#f59e0b` como SVG inline - Heatmap de costos en canvas BPMN
- `pointer-events: none` para no interferir con clicks en el nodo - Reporte web básico con tabla de actividades y gráfico de barras
- `title` nativo del browser como tooltip
- **Por qué `#fffbeb` y no `#ffffff`**: el fondo de los nodos BPMN también es blanco, el fondo ámbar le da identidad cromática propia y hace visible el círculo por contraste
**WorkspacePage** (`src/features/workspace/WorkspacePage.tsx`):
- Destructura `activities` del process-store
- Calcula `automatableElementIds` con `useMemo`
- Pasa `useDeferredValue(automatableElementIds)` al BpmnCanvas (evita cascada de re-renders al editar el panel)
**AutomationScenarioNote** (`src/features/report/AutomationScenarioNote.tsx` — nuevo):
- Componente de leyenda para el tab "Automatizado" del reporte
- Preparado y listo para ser colocado por Etapa 3 — no tiene lógica propia
### Etapa 3 — Motor dual y reporte con tabs (completada)
**ReportPage ampliada** (`src/features/report/ReportPage.tsx`):
- La página ahora tiene 3 tabs: **Actual** | **Automatizado** | **Comparación + ROI**
- El contenido existente (KpiBar, heatmap, 3 gráficos, tabla de actividades) pasa intacto al tab "Actual"
- Los tabs "Automatizado" y "ROI" se deshabilitan si la simulación no tiene `resultAutomated` (snapshot viejo) — en ese caso se muestra `NeedsResimulate` que guía al usuario a re-simular
- `useMemo` para `automatableIds` y `roi` se declaran antes de los early returns (cumple reglas de hooks)
- Los refs de heatmap son independientes por tab (`heatmapRef` / `heatmapRefAuto`) — el tab "Actual" sigue siendo el fuente de imagen para el export PDF
- Export PDF/CSV sin cambios — Etapa 4 los ampliará
**Tab "Automatizado"**:
- Mismo layout que "Actual": KpiBar → heatmap con cross-linking → 3 gráficos → tabla
- Usa `simulation.resultAutomated` como fuente de datos
- `AutomationScenarioNote` aparece al pie del heatmap (componente preparado en Etapa 2)
- Heatmap y tabla tienen su propio estado `selectedIdAuto` — cross-linking funciona independientemente del tab "Actual"
**Tab "Comparación + ROI"**:
- `RoiKpiBar` (`src/features/report/RoiKpiBar.tsx` — nuevo): 5 KPI cards en grid 2×5 (lg)
- Ahorro por ejecución (con % sobre costo actual)
- Ahorro anual
- Payback (meses o años según magnitud; "Inmediato" si inversión=0; "No se recupera" si Infinity)
- Ahorro acumulado neto a N años
- ROI anual % ("∞%" si inversión=0 y ahorro>0)
- Los valores positivos se muestran en `text-emerald-700`, negativos en `text-red-600`
- `ComparisonTable` (`src/features/report/ComparisonTable.tsx` — nuevo):
- Tabla actividad por actividad: costo actual | costo automatizado | ahorro | % ahorro
- Ícono Bot ámbar en la primera columna para actividades marcadas `automatable`
- Ordenable por ahorro (↕) — default descendente
- Usa `Set<bpmnElementId>` para el lookup de `automatable` en O(1)
- `CumulativeSavingsChart` (`src/features/report/CumulativeSavingsChart.tsx` — nuevo):
- Gráfico de línea + área (ECharts): X=años 0..N, Y=ahorro neto acumulado
- Línea de break-even en y=0 siempre visible
- Marker vertical dashed verde en `x=paybackYears` (solo si finite y dentro del horizonte)
- Tooltip muestra valor del año exacto con color según signo
- `TopSavingsChart` (`src/features/report/TopSavingsChart.tsx` — nuevo):
- Top 5 actividades por ahorro positivo — barras horizontales verdes
- Excluye actividades con ahorro ≤ 0
- Altura dinámica: `max(180, n * 48)` px — no hay espacio vacío si hay menos de 5 actividades
- `TransparencySection` (`src/features/report/TransparencySection.tsx` — nuevo):
- Condicional — solo se renderiza si hay issues metodológicos detectados
- **Issue 1**: actividades con `automatable=true` pero `automatedCostFixed=0` y `automatedTimeMinutes=0` → el ahorro puede estar inflado. Lista los nombres de cada actividad
- **Issue 2**: `automationInvestment=0` → payback "inmediato" e ROI "infinito" no reflejan la realidad
- Fondo amber-50, borde amber-200 — consistente con el helper del ActivityPanel
**Por qué ROI inline y no en `useReportData`**:
El `calculateRoi` toma `simulation.result.totalCost` y `simulation.resultAutomated.totalCost` — valores ya disponibles en el componente. Moverlo al hook agregaría un `useEffect` extra sin beneficio. Se mantiene en el componente como `useMemo`.
**Tests nuevos** (17 tests):
- `tests/features/workspace/ActivityPanel.test.tsx`: 6 casos — toggle, helper ámbar con `aria-hidden`, dispatch de `updateActivity`
- `tests/features/workspace/GlobalSettingsPanel.test.tsx`: 7 casos — defaults, edición de campos, dispatch de `updateProcess`
- `tests/features/workspace/BpmnCanvas.test.tsx`: 4 casos — overlay add/remove, posición `left=width-12`, HTML con `#fffbeb` y `#f59e0b`
**Patrón de mocks para tests de componentes** (documentado para referencia futura):
- Los mocks del store con `vi.hoisted()` devuelven referencias estables
- Sin `vi.hoisted`: cada llamada a `useProcessStore()` devuelve un nuevo objeto → el `useEffect([activities])` de ActivityPanel dispara en loop infinito → "Maximum update depth exceeded"
- `simulation-store.ts` importa `process-store.ts` al nivel de módulo: los mocks deben ser compatibles o el store no carga
---
## [0.1.0-mvp] — MVP Fase 0 — 2026-05-13
### Contexto
Primera entrega funcional. Construida en una sesión de trabajo.
Objetivo: demostrar en ≤15 minutos que se puede transformar un BPMN en un reporte de costos.
### Funcionalidades
- Importar BPMN 2.0 por drag & drop o desde procesos de ejemplo
- Renderizar diagrama en canvas read-only (bpmn-js Viewer)
- Configurar actividades: costo fijo, tiempo, recursos asignados con utilización
- CRUD de recursos (rol/persona/sistema/equipo/insumo + costo/hora)
- Configuración global: moneda, overhead %, nombre proceso, cliente
- Motor de simulación determinístico con propagación de probabilidades por gateways XOR, AND, inclusivo
- Detección de loops (back-edges via DFS): se advierte pero no bloquea
- Reporte visual: mapa de calor en dos modos (relativo/absoluto), KPIs, top actividades, composición, costo por recurso
- Export PDF: 3 páginas (header+KPIs+heatmap, análisis, tabla detalle), con jsPDF-autotable
- Export CSV: con BOM UTF-8 y metadatos del proceso
- Persistencia en IndexedDB (Dexie v1)
- Deploy estático en Cloudflare Pages
### Decisiones de diseño relevantes para el futuro
**Parser BPMN multi-pool** (`bpmn-parser.ts`):
- Itera todos los `<bpmn:process>` hijos directos de `<definitions>` (no solo el primero)
- Fix crítico: el parser original usaba `querySelector` que retorna solo el primero → todos los pools de una colaboración excepto el primero eran ignorados
- Pools de soporte (SAP, bandeja manual) pueden no tener StartEvent propio — la validación exige al menos uno entre todos los pools
**Heatmap sobre bpmn-js** (`HeatmapCanvas.tsx`):
- bpmn-js rechaza la promesa de `importXML` pero el diagrama se renderiza parcialmente → usar `eventBus.on('import.done', ...)` en lugar de `.then()`
- `element.style.fill = color` (NO `setAttribute('fill', color)`): bpmn-js aplica CSS que tiene mayor especificidad que atributos SVG
- `captureImage()` espera 500ms después de importar para que la transición CSS `fill 0.35s ease` complete antes de la captura con html2canvas
**Validación XOR en WorkspacePage** (`WorkspacePage.tsx`):
- `hasLoadedOnce` ref: evita que el redirect "proceso no encontrado" dispare en el render inicial donde `isLoading=false` y `currentProcess=null` (antes del primer efecto de carga)
### Tests
268 tests unitarios + integración al cierre de MVP.
5 tests E2E con Playwright (import → simulate → export PDF/CSV × 3 BPMNs + acentos + gradiente de color).
El test de gradiente verifica con `sharp` que el heatmap del PDF contiene píxeles verdes, ámbar y rojos — evita regresiones en la lógica de coloreo.
---
## Convenciones de este changelog
- **Decisiones de diseño**: se documenta el *por qué*, no solo el *qué*
- **Gotchas**: comportamientos contrarios a la intuición se marcan explícitamente
- **Edge cases**: si un edge case motivó un test específico, se menciona
- **Retrocompatibilidad**: cuando un campo es opcional por retrocompat, se indica
- Las entradas son para humanos que modificarán el código, no para usuarios finales

View File

@@ -199,6 +199,81 @@ Antes de presentar cualquier PDF o pantalla:
--- ---
## Gestión de versiones y commits
> Aplica a partir de Sprint 8. Claude Code es responsable del ciclo completo de versionado.
### Conventional Commits — obligatorio en todos los commits
```
feat(scope): descripción ← feature nueva visible al usuario
fix(scope): descripción ← corrección de bug
refactor(scope): descripción ← refactor sin cambio de comportamiento externo
test(scope): descripción ← solo tests, sin cambio de código de producción
docs(scope): descripción ← solo documentación
chore(scope): descripción ← tareas de infra, build, deps
```
`scope` es el área afectada: `auth`, `workspace`, `library`, `report`, `pdf`, `admin`, `db`, `e2e`, etc.
Ejemplos correctos:
```
feat(workspace): back button contextual con navegación a biblioteca del cliente
fix(process-repo): cambiar upsert por update para evitar 403 en client_editor
refactor(auth): agregar isProfileLoaded para evitar race condition de roles
```
### CHANGELOG.md
Actualizar al cerrar cada etapa. Formato Keep a Changelog:
```markdown
## [Unreleased]
### Added
- Back button contextual en workspace para admin
### Fixed
- 403 en Guardar configuración para client_editor (upsert → update)
```
Al cerrar un sprint, mover `[Unreleased]` a `[vX.Y.Z] - YYYY-MM-DD`.
### SemVer — criterios
- **MAJOR:** cambio incompatible (nuevo modelo de datos, cambio de rol fundamental, migración forzada de usuarios)
- **MINOR:** feature nueva visible al usuario (nueva página, nueva funcionalidad en workspace)
- **PATCH:** corrección de bug o mejora de UX sin feature nueva
Sprint 7 = `v0.7.x` (MVP multi-tenant). Sprint 8 arranca en `v0.8.0`.
### Secuencia de cierre de etapa
Al completar una etapa con cambios en producción:
```bash
git add -A
git commit -m "feat(scope): descripción concisa"
# Actualizar CHANGELOG.md
git add CHANGELOG.md
git commit -m "docs(changelog): etapa N sprint X"
git push origin main
```
Al cerrar un sprint completo, agregar además:
```bash
git tag -a vX.Y.Z -m "Release vX.Y.Z: descripción del sprint"
git push --tags
```
### Versión visible en la app
La versión se lee de `package.json` (`version` field) y se muestra en el footer de todas las páginas (o al menos en `/library` y `/report`). Formato: `InQ ROI v1.2.3`.
Si no existe un componente de footer global, crearlo al implementar esta feature por primera vez.
---
## Política de iniciativa proactiva ## Política de iniciativa proactiva
Antes de aplicar un cambio fuera del scope del prompt actual, evaluar en orden: Antes de aplicar un cambio fuera del scope del prompt actual, evaluar en orden:

21
Dockerfile Normal file
View File

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

View File

@@ -1,9 +1,9 @@
# OBSERVATIONS — Sprint 3 # OBSERVATIONS — Sprint 8
> Hallazgos descubiertos durante el sprint actual. > Hallazgos descubiertos durante el sprint actual.
> Este archivo se vacía al cerrar el sprint: los hallazgos se promocionan a TECH_DEBT.md o BACKLOG.md, o quedan resueltos. > Este archivo se vacía al cerrar el sprint: los hallazgos se promocionan a TECH_DEBT.md o BACKLOG.md, o quedan resueltos.
> >
> **Sprint 2 cerrado el 2026-05-20.** Ver cierre en `methodology/CASE_STUDIES/inq-roi-sprint-2.md`. > **Sprint 7 cerrado el 2026-07-07.** Ver reflexiones completas en `STRATEGIC_DIRECTION.md`.
--- ---
@@ -18,13 +18,13 @@
## Hallazgos activos ## Hallazgos activos
*(vacío — sprint recién abierto)* *(vacío)*
--- ---
## Hallazgos resueltos durante el sprint ## Hallazgos resueltos durante el sprint
*(vacío)* *(vacío — sprint recién abierto)*
--- ---
@@ -44,12 +44,44 @@
## Cierre del sprint (checklist) ## Cierre del sprint (checklist)
Cuando el sprint cierre, ejecutar:
``` ```
[ ] Verificar que todos los items [CURRENT-SPRINT] están RESOLVED [ ] Verificar que todos los items [CURRENT-SPRINT] están RESOLVED
[ ] Mover items [DEFERRED-TECH-DEBT] a TECH_DEBT.md (si no están ya) [ ] Mover items [DEFERRED-TECH-DEBT] a TECH_DEBT.md
[ ] Mover items [DEFERRED-BACKLOG] a BACKLOG.md (si no están ya) [ ] Mover items [DEFERRED-BACKLOG] a BACKLOG.md
[ ] Archivar o purgar este archivo [ ] Archivar o purgar este archivo
[ ] Crear OBSERVATIONS.md vacío para el sprint siguiente [ ] Crear OBSERVATIONS.md vacío para el sprint siguiente
``` ```
---
## Aprendizajes de Sprint 7 relevantes para Sprint 8
> Cosecha de método — decisiones y patterns emergentes de Sprint 7 que informan cómo trabajar en Sprint 8.
### Race condition en roles: `isProfileLoaded` como guard
`buildAuthUser()` asigna `platformRole: 'guest'` heurísticamente (por dominio de email) antes de que `syncProfileFromDB` complete. El rol correcto llega ~200ms después. Cualquier UI condicionada por rol que renderice antes de ese sync muestra el estado incorrecto.
**Patrón validado:** `isProfileLoaded: boolean` en AuthContext — se pone en `true` solo después de que `syncProfileFromDB` completa. Componentes que dependen del rol deben esperar `isProfileLoaded` antes de renderizar contenido rol-dependiente.
**Aprendizaje para Sprint 8:** cualquier componente nuevo que condicione su contenido por `platformRole` debe incluir `isProfileLoaded` como prerequisito en su spec de implementación.
### JOIN vs fetch separado: orgName en getById
Fetch separado en `useEffect` para obtener el nombre de la org en `WorkspacePage` fue frágil: race conditions entre `currentProcess` cargando y el efecto, resets de estado al desmontar, y respuesta en array cuando `.maybeSingle()` esperaba objeto. El síntoma (botón mostrando "Biblioteca" en lugar de "Solar Banco") requirió ~8 iteraciones de diagnóstico.
**Fix definitivo:** JOIN en `getById()` con `select('*, org:organizations!org_id(name)')`. El dato llega embebido en el proceso — sin async adicional, sin estado local, sin race condition.
**Aprendizaje para Sprint 8:** cuando un componente necesita datos relacionados de otra tabla para display, preferir JOIN en el repositorio sobre fetch separado en el componente. Los fetches separados en `useEffect` con datos del store como dependencia son estructuralmente frágiles.
### Dos sistemas paralelos de "cliente" → consolidar en Sprint 8
`process_groups`, `clientName` (texto libre) y `org_id` intentaban modelar el mismo concepto ("a qué cliente pertenece el proceso") desde tres ángulos distintos. La confusión generó bugs de UX (back button, agrupación en biblioteca) y deuda conceptual.
**Decisión tomada:** Sprint 8 consolida en `organizations → areas → processes`. Ver `STRATEGIC_DIRECTION.md` → "Consolidación del modelo de cliente".
### Diagnóstico por DOM observable > teorización de librerías
El bug del freeze de perfil (Sprint 5, confirmado resuelto en Sprint 6) y el bug del back button (Sprint 7) compartieron el mismo patrón: múltiples iteraciones de diagnóstico teórico sin resolver → un dato observable del runtime (HTML del DOM, Network tab, respuesta de request) colapsó el espacio de hipótesis en 1 paso.
**Aprendizaje consolidado:** ante bug de UI que persiste 2+ fixes teóricos, pedir el dato observable antes de seguir. Específicamente: HTML del elemento, Network tab con el request real, o `console.log` del valor en runtime.

View File

@@ -258,4 +258,246 @@ Sprint 2 tuvo 4 etapas de web app (1, 2, 3, 4), 2 de PDF (5, 6) y 0 de infraestr
- **Modelo B (AS-IS / TO-BE):** ¿abrimos esa puerta o seguimos con Modelo A y mejoramos la experiencia del modelo actual? - **Modelo B (AS-IS / TO-BE):** ¿abrimos esa puerta o seguimos con Modelo A y mejoramos la experiencia del modelo actual?
- **Catálogo global de recursos:** ¿el valor de compartir recursos entre procesos justifica la complejidad en Sprint 3, o esperar a Sprint 4 con más procesos cargados? - **Catálogo global de recursos:** ¿el valor de compartir recursos entre procesos justifica la complejidad en Sprint 3, o esperar a Sprint 4 con más procesos cargados?
- **Indicador de simulación desactualizada:** alto impacto en talleres, bajo esfuerzo. Candidato a entrar en Sprint 3. - **Indicador de simulación desactualizada:** alto impacto en talleres, bajo esfuerzo. Candidato a entrar en Sprint 3.
- **Posicionamiento:** post-Sprint 2 es el momento comprometido para una primera conversación sobre herramienta interna vs. venta externa. - **Posicionamiento:** post-Sprint 2 es el momento comprometido para una primera conversación sobre herramienta interna vs. venta externa.
---
## Reflexiones del cierre de Sprint 4
> Agregado el 2026-06-23 al cerrar formalmente Sprint 4 y 5 juntos (Sprint 5 Etapa 6).
### Decisión estratégica
Refactor arquitectural completo: migración de IndexedDB (Dexie) a Supabase, con autenticación Google Workspace OAuth. **Sin features de producto nuevas** — el sprint completo fue inversión en infraestructura.
**Justificación:** la deuda técnica de IndexedDB (persistencia local, sin colaboración multi-usuario) bloqueaba cualquier feature colaborativa futura (compartir procesos entre consultores, catálogo de recursos entre cuentas, identidad de "quién editó qué"). Postergar el refactor habría compuesto el costo de migración a medida que crecía el volumen de datos en producción.
### Resultado
Auth Google OAuth + Supabase, migración completa IndexedDB→PostgreSQL, RLS (`is_platform_admin()` + políticas granulares), catálogo global de recursos (sin `processId`), identidad en UI (avatar/dropdown, "Actualizado por"), `ResourceCatalogPage` + `ResourceFormSheet`, fix Web Locks API deadlock (`processLock`). ~550+ tests al cierre.
**Cerrado:** 2026-06-18.
---
## Reflexiones del cierre de Sprint 5
> Agregado el 2026-06-23 al cerrar formalmente el sprint.
### Decisión estratégica
Retorno al rebalanceo normal (60-70% features de producto web, 10-15% infraestructura/deuda técnica) tras el sprint 100% infraestructural de Sprint 4. Foco en UX polish y quick wins de alto impacto/bajo esfuerzo — explícitamente sin features de gran escala (historial de simulaciones, `setShared()` real, Keycloak, PWA, i18n, Excel).
### Resultado
Fix crítico workspace loading (race condition auth/processId), zoom BPMN (fit-viewport con padding + controles +//fit), heatmap con gradiente continuo de 4 stops + toggle percentile/min-max persistido en localStorage, "ver procesos por recurso" en catálogo, campo empresa en perfil de usuario, columna "Eficiencia de automatización" en `ComparisonTable` (A/B con "Ahorro esperado" para focus group), reescritura de specs E2E Dexie→Supabase, fix de flakiness en botón "Guardar cambios".
**Hallazgo operativo del sprint:** el bug de freeze al guardar perfil (Etapa 4) requirió 12 commits y ~10 iteraciones de diagnóstico basado en lectura de código fuente de Supabase/Radix antes de resolverse. El quiebre llegó cuando el director compartió el HTML real de la pantalla congelada en vez de seguir teorizando sobre el comportamiento esperado de las librerías — un dato observable (`document.body.style.pointerEvents = 'none'`) colapsó el espacio de hipótesis de inmediato. Ver `sprints/sprint-5/INFORME_ETAPA_4_FREEZE_PERFIL.md` y memoria de feedback asociada.
**Cerrado:** 2026-06-23 (pendiente validación visual final del director sobre Etapas 5 y 6).
---
## Reflexiones del cierre de Sprint 6
> Agregado el 2026-06-24 al cerrar formalmente el sprint.
### Decisión estratégica
Sprint orientado a "client-readiness": cerrar los dos gaps que impedían usar InQ ROI en vivo con un cliente externo — PDF sin marca visual y ROI sin capacidad de análisis what-if. Se abrió también la planificación de Sprint 7 (multi-usuario y acceso de clientes) a partir de una evaluación estratégica de posicionamiento del producto.
### Resultado
**Etapa 1:** logo InQuality en header de PDFs (páginas 2+, isotipo 15mm proporcional, fallback a texto si falla el fetch); `whitespace-nowrap` en MetricCell de Eficiencia. Bug detectado en proceso: Claude Code agregó logo en portada sin autorización (fuera del scope explícito del prompt) — revertido. Aprendizaje de método: auditar archivos adyacentes al scope declarado.
**Etapa 2:** panel de análisis de sensibilidad con dos sliders (volumen 50%/+100%, inversión 50%/+100%), recálculo de KPIs en tiempo real vía `calculateRoi()` sin re-simulación, badge "Análisis activo", versión wow con doble línea en `CumulativeSavingsChart` (base gris dashed + área sombreada + ajustada naranja). Layout del tab ROI reordenado post-validación: `CumulativeSavingsChart` sube por encima de `ComparisonTable` para mantener adyacencia visual con los sliders.
**Etapa 3:** bug real confirmado y corregido — `ProfileSheet.getAccessToken()` leía `'supabase.auth.token'` (clave inexistente en supabase-js v2); el guardado de perfil nunca llegó a Supabase desde Sprint 4. Fix: calcular `sb-${projectRef}-auth-token`. `setTimeout(350)` eliminado de ProfileSheet; fix de `sheet.tsx` es suficiente. Specs E2E de diagnóstico y validación de auth. 552 tests al cierre.
### Decisiones estratégicas tomadas durante el sprint
**Modelo de negocio clarificado:** InQ ROI es herramienta de consultor, no de cliente final. El "trabajo de prep" (modelar BPMN, cargar costos, configurar recursos) es el relevamiento de consultoría facturable. Funnel: relevamiento pago → diagnóstico ROI → venta de automatización. El consultor usa InQ ROI como argumento de precio (de "horas de desarrollo" a "valor percibido por ahorro potencial").
**Clientes piloto definidos:** Banco Solar → Banco Itaú → Aseguradora Yacyretá → Negofin (en ese orden).
**Sprint 7 abierto:** multi-usuario y acceso de clientes. Modelo de 4 roles (`platform_admin`, `member`, `client_editor`, `client_viewer`). Patrón de InQ Sizing (documento de referencia: `cowork/InQ ROI - Simulador de procesos/autenticacion-y-usuarios.md`). InQuality como organización administradora de la plataforma.
**Cerrado:** 2026-06-24.
---
## Reflexiones del cierre de Sprint 7
> Agregado el 2026-07-06 al cerrar formalmente el sprint.
### Decisión estratégica
Sprint de mayor escala arquitectónica desde Sprint 4 (refactorización Dexie→Supabase). Sprint 7 abrió el producto a usuarios externos con control de acceso real: tabla `organizations`, 4 roles de plataforma, Edge Functions de invitación, panel de administración de InQuality, y ajustes de UX para cada rol. La fundación de multi-tenancy está operativa.
### Resultado
**Etapa 1 (DB):** migración 014 — tabla `organizations`, `org_id` en `users` y `processes`, constraint de roles ampliado (platform_admin, member, client_editor, client_viewer, guest). Migración 015 — SECURITY DEFINER helpers (`current_org()`, `is_inquality()`, `current_platform_role()`), trigger `handle_new_user`, RLS completa con 4 roles en 7 tablas.
**Etapa 2 (Edge Functions):** `invite-user` (invita via Supabase Auth Admin API + UPSERT en public.users) y `revoke-user` (baja a 'guest', suspensión suave). Diseño simplificado respecto al BRIEF: 2 funciones en lugar de 3 separadas (create/suspend/delete). Limitación documentada: "eliminar" es soft (baja a guest), hard delete queda para sprint futuro.
**Etapa 3 (Auth layer):** fix crítico — `syncProfileFromDB` ahora lee `platform_role` y `org_id` desde DB en lugar de inferir del email. `AuthUser` extendido con `orgId`. Login email/password. Página `/reset-password` para flujo de invite. Guard `TOKEN_REFRESHED` en `onAuthStateChange`. 558 tests.
**Etapa 4 (Admin UI):** panel `/admin` con guard `platform_admin`, páginas de Organizations y Users con CRUD completo, invitación de usuarios via Edge Function, suspensión/reactivación, badges de rol, skeletons, alertdialogs para acciones destructivas, ícono Shield en AppHeader. 558 tests.
**Etapa 5 (Client UX):** routing por rol — `client_viewer` redirigido desde workspace al reporte, `client_editor` bloqueado de importar BPMNs. Library con lista plana para clientes (sin panel de grupos). Reporte abre en tab ROI para `client_viewer`. Fix bug: `normalizeGroupLabel()` limpia artículos iniciales del label de grupos (dato corrupto en Supabase). 578 tests.
### Decisiones arquitectónicas tomadas en el sprint
| Decisión | Estado al cierre |
|---|---|
| Estrategia de migración: Opción A (auto-crear orgs desde `processes.client`) | Definitiva — migración 014 aplicada |
| Trigger `handle_new_user`: rol `member` por defecto (no `platform_admin`) | Definitiva — confirmada por director |
| UPSERT en Edge Function invite-user (race condition handle_new_user) | Definitiva — patrón de InQ Sizing |
| "Eliminar" usuario como soft delete (baja a 'guest') | Provisional — hard delete en sprint futuro |
| "Reactivar" restaura a 'client_editor' por defecto | Provisional — limitación documentada |
### Patrón recurrente identificado: guards de rol via `useEffect`
En tres etapas (AdminLayout, WorkspacePage, ImportPage), Claude Code implementó guards de rol como efectos de componente en lugar de `beforeLoad` de TanStack Router. El patrón es funcional (RLS garantiza seguridad de datos) pero subóptimo para TanStack Router, que provee `beforeLoad` precisamente para evitar que rutas protegidas monten. Este patrón surgió como heurística del agente cuando el guard se especificó en la descripción del comportamiento en lugar de en la spec de la ruta. **Aprendizaje para Sprint 8+:** especificar guards siempre en la sección de router con el código exacto de `beforeLoad`.
### Lo que funcionó bien
**La auditoría previa de policy names por parte de Claude Code (Etapa 1).** Antes de escribir los DROP POLICY, Claude Code leyó las migraciones 001-013 y reportó los nombres exactos con discrepancias respecto al brief. Sin esta auditoría, `"all_activity_resource_assignments"` habría dejado la policy permisiva original activa. Este comportamiento proactivo de verificación debería solicitarse explícitamente en futuros prompts de migración RLS.
**El flujo de validación con cambio temporal de rol en Supabase Studio.** El director pudo validar el comportamiento de `client_editor` y `client_viewer` sin necesidad de crear usuarios reales de prueba, editando su propio `platform_role` temporalmente. Técnica útil para validación rápida pre-onboarding de clientes reales.
**La normalización de datos en capa de repositorio.** `normalizeGroupLabel()` en `settings-repo.ts` resolvió un dato corrupto en Supabase sin migración ni intervención manual — limpieza transparente al leer/escribir. Patrón replicable para otros datos con formato variable en la DB.
### Estado del producto al cierre de Sprint 7
- Auth completo con 4 roles, organizaciones, y panel de administración operativo
- Primer acceso de cliente posible: invitación → magic link → set password → library filtrada por org → workspace (editor) o reporte (viewer)
- Datos: 578 tests Vitest, build limpio
- Deuda técnica identificada: 2 ítems (guards via useEffect × 3 componentes, doble suscripción onAuthStateChange)
- Backlog identificado: 1 ítem (Settings accesible para roles de cliente)
### Próxima revisión — Sprint 8
**Prioridades candidatas (sin decisión tomada aún):**
- Primer onboarding real de cliente (Banco Solar) — smoke test end-to-end del flujo de invite
- Hard delete de usuarios (Edge Function `delete-user`)
- Migrar guards de rol a `beforeLoad` de TanStack Router
- Email visible en panel admin (requiere join con `auth.users` via service_role)
- Historial de simulaciones (diferido desde Sprint 3)
**Cerrado:** 2026-07-07 (con extensión de hotfixes: Etapa 6 ConfirmInvitePage, fix upsert→update en process-repo, Etapa 7 race condition AuthContext + simulations_update policy, Etapa 7B back button contextual en workspace).
---
## Decisiones estratégicas confirmadas para Sprint 8
> Registradas el 2026-07-07 durante la sesión de cierre de Sprint 7.
### client_editor: permisos de BPMN ampliados
**Decisión del director:** `client_editor` podrá:
1. **Actualizar BPMN de procesos existentes** asignados a su org (reemplazar `bpmn_xml` vía UPDATE)
2. **Importar procesos nuevos** con el botón "Importar BPMN", creándolos con `org_id = current_org()`
**Implicancias técnicas para el brief:**
- `updateEditable()` debe incluir `bpmn_xml` cuando el caller tiene permisos para modificarlo
- Política INSERT en `processes`: agregar rama `client_editor AND org_id = current_org()` (los procesos nuevos quedan dentro de su org)
- Botón "Importar BPMN" visible para `client_editor` (actualmente bloqueado en Etapa 5)
- Los procesos creados por `client_editor` tienen `owner_id = auth.uid()` y `org_id = current_org()`
- `client_editor` NO puede mover procesos entre orgs ni cambiar `owner_id`
**Precaución:** relajar INSERT en processes para client_editor es cambio de modelo. El brief de Sprint 8 debe incluir una migración que ajuste la política y tests E2E que validen el scope (no pueden crear procesos sin org_id, no pueden crear procesos en otra org).
### Versionado profesional con changelog
**Decisión del director:** a partir de Sprint 8, Claude Code es responsable del ciclo completo de versiones:
1. **Commits:** formato Conventional Commits obligatorio
- `feat(scope): descripción` — feature nueva
- `fix(scope): descripción` — corrección de bug
- `refactor(scope): descripción` — refactor sin cambio de comportamiento
- `test(scope): descripción` — solo tests
- `docs(scope): descripción` — solo documentación
- `chore(scope): descripción` — tareas de infra/build
2. **CHANGELOG.md:** actualizar al cerrar cada etapa, formato Keep a Changelog
3. **SemVer para versiones:** `MAJOR.MINOR.PATCH`
- MAJOR: cambio incompatible de producto (nuevo modelo de datos, cambio de rol fundamental)
- MINOR: feature nueva visible al usuario
- PATCH: fix de bug, mejora de UX sin feature nueva
4. **Git tags:** `git tag -a vX.Y.Z -m "Release vX.Y.Z: descripción"` + `git push --tags`
5. **Versión visible en la app:** footer de todas las páginas, leer de `package.json` o `src/lib/version.ts`. Formato: `InQ ROI v1.2.3`.
**Instrucción:** la sección "Gestión de versiones y commits" se agrega a `simulador-web/CLAUDE.md` como Sprint 8 Etapa 0, antes de cualquier feature nueva.
### Página de ayuda (`/help`)
**Decisión del director:** Sprint 8 incluye una ruta `/help` con documentación interna del producto para todos los roles.
**Contenido mínimo:**
- Cómo parametrizar actividades (tiempos, costos, recursos)
- Cómo simular y leer el reporte
- Cómo exportar PDF y CSV
- Referencia rápida de los campos del panel Global
**Requisito de método añadido al pipeline de sprints:** cualquier `ETAPA_X_PROMPT.md` que modifique la UI incluirá un ítem en los criterios de validación: `[ ] Verificar si el cambio afecta el contenido de /help — actualizar si corresponde`. Agregar al template de etapa en `methodology/` antes de abrir Sprint 8.
### Botón "volver" contextual en workspace
**Decisión del director:** implementado en Sprint 7 como Etapa 7B. Brief en `sprints/sprint-7/ETAPA_7B_PROMPT.md`.
Comportamiento **post-hotfix (2026-07-07):**
- Admin en proceso de cliente → "← [nombre del cliente]" → `/library` (sin `?org=`)
- Admin en proceso sin org → "← Biblioteca" → `/library`
- client_editor/viewer → "← Procesos" → `/library`
**Nota:** La navegación con `?org=<uuid>` + scroll fue descartada porque `LibraryPage` agrupa por `clientName` (texto libre), no por `org_id`. No existe una vista filtrada por org. El label contextual se mantiene ("← Solar Banco") porque aporta información al usuario; la navegación destino simplifica a `/library` hasta que exista la vista filtrada.
---
### Consolidación del modelo de cliente: `organizations` → `areas` → procesos
**Decisión del director (2026-07-07):** reemplazar los tres conceptos paralelos de "cliente" por una jerarquía unificada en dos niveles.
**Estado actual — tres conceptos no reconciliados:**
| Campo | Uso actual | Problema |
|---|---|---|
| `process_groups` (tabla) | Agrupador "Clientes" en LibraryPage | Nombre confuso, duplica el rol de organizations |
| `clientName` (texto libre) | Editable en workspace sidebar | Sin normalización, sin FK, sin validación |
| `org_id` (FK a organizations) | Multi-tenant Sprint 7 | Correcto pero desconectado de lo que muestra la UI |
**Modelo objetivo:**
```
organizations (org_id) ← el cliente → entidad registrada por admin
└─ areas (area_id) ← área / equipo / unidad → normalizado por org, creado por admin
└─ processes ← tiene org_id + area_id (ambos nullables con lógica clara)
```
**Implicancias técnicas:**
- Nueva tabla `areas (id, org_id, name, description?, created_at)` — reemplaza `process_groups`
- Campo `area_id uuid REFERENCES areas(id)` reemplaza `group_id` en `processes`
- Campo `clientName` (texto libre) se elimina del dominio — el display viene de `org.name` + `area.name`
- `LibraryPage` agrupa por `org_id` (organización) y sub-agrupa por `area_id` (área)
- Workspace sidebar: el campo "cliente" pasa a ser un selector de área (dropdown de áreas de la org del proceso)
- Admin UI: CRUD de áreas por org (similar a CRUD de resources, scoped por org)
- RLS: `areas_select` — acceso transitivo por org (si ves la org, ves sus áreas)
**Datos actuales:** los 154 procesos "sin cliente asignado" son datos de prueba. El plan de migración es:
1. Bulk-assign a InQuality org (`UPDATE processes SET org_id = '<inq_uuid>' WHERE org_id IS NULL`) o eliminarlos
2. Los `process_groups` existentes ("david-dev", "InQuality") se eliminan — eran grupos de prueba
3. Sin datos reales en riesgo — el producto no tiene clientes externos activos al momento de la migración
**Sprint objetivo:** Sprint 8. Es refactorización de modelo de datos con impacto en DB + repositorios + UI. No entra en Sprint 5 (UX polish en curso). El brief de Sprint 8 incluye esta consolidación como prioridad 1 antes de features nuevas.
**Criterio de done:**
- `process_groups` eliminada (con migración que dropea la tabla y transfiere datos si aplica)
- `clientName` eliminado del tipo `Process` y de la UI
- `areas` table con RLS + CRUD en Admin
- `LibraryPage` agrupa por org → área
- Workspace sidebar muestra org asignada (read-only para client_editor) + selector de área
- Tests E2E: flujo admin crea org → crea área → asigna proceso → client_editor ve sus procesos agrupados

View File

@@ -47,6 +47,93 @@ en los 3 PDFs. Si hay truncamiento, evaluar:
## Deuda media — afecta DX o evolución del producto ## Deuda media — afecta DX o evolución del producto
### [RESOLVED] Clave de localStorage del auth E2E vs producción
**Resultado:** la "contradicción" era un bug real, no solo una discrepancia de documentación.
`tests/e2e/auth-localStorage-key.spec.ts` (Sprint 6 Etapa 3) confirmó empíricamente que la
única clave presente en `localStorage` con sesión real es `sb-${projectRef}-auth-token`
(`sb-xgqbkxcliyjzisoccsur-auth-token` en este proyecto) — `'supabase.auth.token'` (STORAGE_KEY
default de `@supabase/auth-js`'s `GoTrueClient` en aislamiento) **no existe**, porque
`@supabase/supabase-js`'s `createClient()` sobrescribe ese default calculando la clave
`sb-`-prefijada desde la URL del proyecto.
`ProfileSheet.getAccessToken()` leía `'supabase.auth.token'` — la clave vieja, inexistente.
Esto significaba que **el guardado de perfil (nombre, empresa) nunca llegaba a Supabase**:
`getAccessToken()` retornaba `null`, el `if (!token) return` silenciaba el error, y el usuario
veía el cambio solo en memoria (`updateUser()`) sin persistir. Bug real, no solo deuda de
documentación.
**Fix:** `getAccessToken()` ahora calcula `sb-${projectRef}-auth-token` igual que
`auth-setup.ts`. Verificado end-to-end: PATCH a `/rest/v1/users` devuelve 204, y tras un
reload completo de página el valor guardado se relee correctamente desde la DB.
**Sprint cerrado en:** Sprint 6 Etapa 3.
### [BACKLOG] Guards de rol via `useEffect` en lugar de `beforeLoad` (3 componentes)
**Estado actual:** `AdminLayout`, `WorkspacePage`, e `ImportPage` usan `useEffect` para detectar el rol y redirigir. El componente monta brevemente antes del redirect — posible flash de contenido.
**Causa raíz:** el pattern surgió de prompts que describieron el guard en términos de comportamiento de componente ("si el rol no es X, redirigir") en lugar de especificar la sección de router.
**Impacto:** no es bug de seguridad (RLS protege los datos en Supabase), pero es anti-patrón para TanStack Router y puede causar flash visual breve en conexiones lentas.
**Solución propuesta:** migrar a `beforeLoad` de TanStack Router. Ejemplo: `beforeLoad: ({ context }) => { if (context.auth.user?.platformRole !== 'platform_admin') throw redirect({ to: '/library' }) }`.
**Archivos afectados:** `src/features/admin/AdminLayout.tsx`, `src/features/workspace/WorkspacePage.tsx`, `src/features/import/ImportPage.tsx` (aprox. línea 65-70 en WorkspacePage).
**Aprendizaje de método:** en prompts futuros que requieran guards de rol, especificar siempre el código exacto de `beforeLoad` en la sección de router — no solo el comportamiento esperado del componente.
**Origen:** Sprint 7 Etapa 5 — patrón recurrente detectado en 3 componentes.
### [BACKLOG] Doble suscripción `onAuthStateChange` en AuthContext
**Estado actual:** `AuthContext.tsx` tiene dos listeners de `onAuthStateChange`: uno principal (SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, INITIAL_SESSION) y uno secundario para `PASSWORD_RECOVERY`. El secundario fue implementado para evitar importación circular entre `AuthContext` y `SupabaseAuthService`.
**Causa raíz:** `PASSWORD_RECOVERY` necesita llamar a `supabase.auth.updateUser()` directamente desde el contexto — si se delega a `SupabaseAuthService`, este importa desde `AuthContext` creando un ciclo.
**Impacto:** ambos listeners se registran en cada mount del `AuthProvider`. En la mayoría de los casos no genera bugs, pero el patrón es frágil y puede causar doble-dispatch de eventos en casos edge de remount.
**Solución propuesta:** refactorizar para un único switch/case en el listener principal. Resolver la importación circular moviendo `supabase` client a un módulo compartido sin dependencia de AuthContext.
**Archivos afectados:** `src/auth/AuthContext.tsx`.
**Origen:** Sprint 7 Etapa 3 — documentado en OBSERVATIONS.md.
### [BACKLOG] `getCurrentUser()` en SupabaseAuthService — sin callers activos
**Estado actual:** `SupabaseAuthService.getCurrentUser()` usa `getSession()` directamente (sin timeout, sin processLock workaround). Es la única excepción documentada a la regla — justificada porque necesita el objeto sesión completo, no solo una barrera. Sin callers activos hoy.
**Impacto si no se resuelve:** ninguno mientras no tenga callers. Si en el futuro algún flujo llama `getCurrentUser()`, podría colgar el processLock si el servidor no responde.
**Solución propuesta:** cuando adquiera callers reales, evaluar si `supabase.auth.getUser()` (token de localStorage, sin lock) es suficiente para ese caso.
**Archivos afectados:** `src/auth/SupabaseAuthService.ts` (~línea 27, comentario explicativo presente).
**Origen:** OBSERVATIONS Sprint 5 Etapa 1 — ítem prometido que faltaba registrar acá.
### [RESOLVED] `setTimeout(350)` en ProfileSheet — candidato a limpieza
**Resultado:** `setTimeout` eliminado — `updateUser()` se llama directamente tras
`onOpenChange(false)`. Verificado end-to-end (Sprint 6 Etapa 3): el fix definitivo en
`src/components/ui/sheet.tsx` (`useEffect` sobre el prop `open`, limpieza defensiva de
`pointer-events`) es suficiente por sí solo — `document.body.style.pointerEvents` queda
vacío tras guardar, y la navegación posterior (click en "Importar BPMN") funciona sin
bloqueos.
**Sprint cerrado en:** Sprint 6 Etapa 3.
### [RESOLVED] E2E specs con Dexie directo — reescribir para Supabase
**Resultado:** `export-pdf.spec.ts` (test "Heatmap con gradiente real") y `validate-dod-etapa4.spec.ts` reescritos para inyectar datos vía `@supabase/supabase-js` con `SUPABASE_SERVICE_ROLE_KEY` (bypasa RLS), en lugar de IndexedDB directo. `afterAll` borra el proceso creado por cada test (cascada en `activities`/`simulations`). Si `SUPABASE_SERVICE_ROLE_KEY` no está configurada en `.env.test`, ambos tests hacen `test.skip()` con mensaje descriptivo en vez de fallar — ver `.env.example`. Diagnóstico previo de `auth-setup.ts` confirmó que la storageKey (`sb-${projectRef}-auth-token`) ya era correcta (no era parte del problema): `@supabase/supabase-js`'s `createClient()` sobrescribe el `STORAGE_KEY` default de `auth-js` con esa clave calculada desde la URL.
**Sprint cerrado en:** Sprint 5 Etapa 6.
### [RESOLVED] E2E flakiness — "Guardar cambios" button timing en ActivityPanel
**Resultado:** agregado `data-testid="save-activity-btn"` al botón en `ActivityPanel.tsx`. Specs afectados (`payback-etapa-2.spec.ts`, `export-roi.spec.ts`) reemplazan el click directo por `waitForSelector(':not([disabled])')` → click → `waitForSelector('[disabled]')` (confirma que el guardado se procesó). Confirmado estable en 3 corridas consecutivas sin fallos. `GlobalSettingsPanel.tsx` tiene el mismo patrón `isDirty`/disabled en su botón "Guardar configuración" pero no se tocó — fuera del scope de Etapa 6; queda como ítem nuevo abajo.
**Sprint cerrado en:** Sprint 5 Etapa 6.
### [BACKLOG] GlobalSettingsPanel — mismo patrón de flakiness potencial que ActivityPanel
**Estado actual:** `GlobalSettingsPanel.tsx` tiene un botón "Guardar configuración" con el mismo guard `disabled={!isDirty}` que `ActivityPanel.tsx` tenía antes de Etapa 6. La función `configureGlobal()` en `payback-etapa-2.spec.ts` y `export-roi.spec.ts` sigue usando `getByRole('button', { name: /Guardar/i }).click()` sin esperar a que se habilite — el mismo riesgo de flakiness, no confirmado como activo todavía.
**Impacto:** potencial, no confirmado. Estos specs pasaron 3/3 en Etapa 6 sin tocar esta función.
**Solución propuesta:** agregar `data-testid="save-global-btn"` a `GlobalSettingsPanel.tsx` y aplicar el mismo patrón de `waitForSelector` que se usó para `save-activity-btn`.
**Costo:** bajo. Mismo patrón ya validado.
**Origen:** Sprint 5 Etapa 6 — detectado durante el diagnóstico, explícitamente fuera del scope del prompt (solo `ActivityPanel.tsx` listado en archivos a modificar).
### [BACKLOG] 9 specs adicionales con el mismo patrón flaky de "Guardar" sin data-testid
**Estado actual:** además de los 2 specs corregidos en Etapa 6, otros 9 archivos (`sprint-1-5-final.spec.ts`, `screenshots-etapa-3.spec.ts`, `etapa-4-pdfs.spec.ts` a `etapa-9-badge.spec.ts`, `etapa-11-polish.spec.ts`) usan el mismo `getByRole('button', { name: /Guardar/i }).click()` sin esperar a `isDirty`. No confirmados como flaky activamente — fuera del scope explícito de Etapa 6 (que listaba solo 2 archivos a modificar).
**Impacto:** riesgo de flakiness latente, no confirmado.
**Solución propuesta:** aplicar el mismo patrón `waitForSelector('[data-testid="save-activity-btn"]:not([disabled])')` ahora que el testid existe en producción.
**Costo:** bajo, mecánico — mismo cambio repetido 9 veces.
**Origen:** Sprint 5 Etapa 6 — identificado durante el grep de diagnóstico, no aplicado por respeto al scope explícito del prompt.
### [BACKLOG] CumulativeSavingsChart — derivación de inversión base desde cumulativeSavingsHorizon
**Estado actual:** cuando `roiBase` está presente (modo sensibilidad activo), el componente
recupera la inversión original algebraicamente: `investment_base = roiBase.annualSavings * horizonYears - roiBase.cumulativeSavingsHorizon`.
Es correcto mientras `horizonYears` del chart coincida con el usado en `calculateRoi` (hoy siempre es el mismo).
**Riesgo:** si en el futuro el chart recibe un horizonte diferente al del cálculo de ROI, el valor
de referencia del eje Y para la línea base será incorrecto, sin error visible.
**Solución propuesta:** agregar prop `investmentBase?: number` a `CumulativeSavingsChartProps`
y pasar `process.automationInvestment` directamente desde `ReportPage`.
**Costo:** bajo. **Origen:** Sprint 6 Etapa 2 — detectado en auditoría post-implementación.
### [INVESTIGATING] Dexie live queries (sincronización store ↔ db) ### [INVESTIGATING] Dexie live queries (sincronización store ↔ db)
**Estado actual:** la invalidación del resultado de simulación se garantiza vía ESLint rule (`no-restricted-imports`) + convención: todas las mutaciones pasan por `useProcessStore`. **Estado actual:** la invalidación del resultado de simulación se garantiza vía ESLint rule (`no-restricted-imports`) + convención: todas las mutaciones pasan por `useProcessStore`.

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,37 @@
## Estado actual del proyecto ## Estado actual del proyecto
**Sprint:** 3 (próximo — planificación pendiente) **Sprint:** 5 (en curso — BRIEF listo en `sprints/sprint-5/BRIEF.md`)
**Fase:** 1 — ROI y escenarios de automatización **Fase:** 1 — ROI y escenarios de automatización
**Deploy activo:** https://process-cost-platform.pages.dev **Deploy activo:** https://inq-roi.inqualityhq.com (Dokploy) · 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 4 cerrado el 2026-06-18.** Entregó: auth Supabase + Google Workspace OAuth, migración IndexedDB→PostgreSQL, RLS con `is_platform_admin()`, catálogo global de recursos (sin `processId`), identidad en UI (avatar/dropdown AppHeader, "Actualizado por"), ResourceCatalogPage + ResourceFormSheet, fix Web Locks API deadlock (`processLock`), fix `.maybeSingle()` en simulation-repo, fix `formatCostPerHour()` precisión dinámica, fix Google OAuth `prompt: 'select_account'`, catálogo con contexto de proceso (banner + query param `fromProcess`), batería E2E Sprint 4 + `docs/CHECKLIST_ENTREGA.md`. ~550+ tests.
**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. **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 5 — 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? UX polish + quick wins. Retorno al rebalanceo normal (60-70% web). Sin features de gran escala.
- **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).
Ver backlog completo en `simulador-web/BACKLOG.md`. - **Fix crítico:** workspace loading — se queda cargando sin refresh
- **Fix + mejora:** zoom BPMN centering + controles zoom-in/zoom-out
- **Heatmap:** gradiente continuo (percentil default, toggle min-max)
- **Catálogo:** procesos por recurso + campo empresa en perfil
- **Reporte:** columna eficiencia de automatización en ComparisonTable
- **Deuda técnica:** E2E Dexie→Supabase + flakiness "Guardar"
Ver `sprints/sprint-5/BRIEF.md` para scope completo.
## Sprint 4 — dirección estratégica (cerrado)
Refactorización arquitectural completa. Sin features de producto nuevas. Ver `sprints/sprint-4/BRIEF.md`.
--- ---
@@ -70,7 +82,7 @@ Historial de simulaciones y comparación de escenarios diferido a Sprint 3.
- Stack tecnológico (React + Vite + TypeScript + stack definido en `simulador-web/CLAUDE.md`) - Stack tecnológico (React + Vite + TypeScript + stack definido en `simulador-web/CLAUDE.md`)
- Modelo de escenarios: Modelo A (un BPMN, dos pares de campos por actividad) - Modelo de escenarios: Modelo A (un BPMN, dos pares de campos por actividad)
- ROI: cálculos nominales simples sin VPN ni Monte Carlo - ROI: cálculos nominales simples sin VPN ni Monte Carlo
- Heatmap: colores saturados `#10B981 / #F59E0B / #EF4444` (revisar Sprint 3-4) - Heatmap: gradiente continuo 4 stops (#10b981#f59e0b#f97316#ef4444), percentil default, toggle min-max (Sprint 5 Etapa 3)
--- ---

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,227 @@
# Sprint 5 — Etapa 4E: Fix definitivo del freeze al guardar perfil
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Supabase v2.106.2.
Etapas 4B, 4C, 4D intentaron corregir el freeze al guardar perfil. Ninguna resolvió el problema. Esta etapa identifica y corrige la causa raíz real.
---
## Causa raíz real — auditada en código fuente de `@supabase/auth-js`
### Componente 1: `getSessionSafe()` abandona Promises en `PROCESS_LOCKS`
`getSessionSafe()` en `src/lib/supabase.ts` está implementada así:
```ts
await Promise.race([
supabase.auth.getSession(), // Promise A — va al chain de processLock
new Promise((resolve) => setTimeout(resolve, 4000)), // timeout 4s
])
```
Cuando el timeout gana, `getSessionSafe()` resuelve. **Pero la Promise A de `getSession()` sigue corriendo en background.** Internamente, `getSession()` llamó `processLock(name, acquireTimeout, fn_session)`, que actualizó `PROCESS_LOCKS[name] = op_A` **antes de ceder el hilo**. La operación `op_A` está en vuelo (token refresh via red) con `PROCESS_LOCKS[name]` apuntando a ella.
### Componente 2: `lockAcquireTimeout = 5000ms` demasiado corto
El cliente Supabase usa `lockAcquireTimeout = 5000ms` por defecto. Cada llamada a `getSession()` pasa ese timeout a `processLock(name, 5000, fn)`.
### La cadena del freeze
```
t=0 → library-store.load() llama getSessionSafe()
→ getSession() → processLock → PROCESS_LOCKS[name] = op_A
→ op_A empieza: token refresh (request HTTP a Supabase Auth)
t=4s → getSessionSafe() timeout gana → resuelve
→ op_A sigue corriendo en background (PROCESS_LOCKS[name] = op_A, PENDIENTE)
t=4s → library queries lanzan: supabaseGroupRepo.getAll() etc.
→ _getAccessToken() → getSession() → _acquireLock(5000, fn_B)
→ processLock(5000): previousOperation = op_A (aún pendiente)
→ espera hasta que op_A resuelva... o hasta 5s
t=9s → ProcessLockAcquireTimeoutError para op_B
→ PROCESS_LOCKS[name] = wrapper_B
→ wrapper_B hace: await previousOperation (= op_A, AÚN COLGADA)
→ wrapper_B nunca resuelve mientras op_A esté colgada
t=X → Usuario abre ProfileSheet → hace click en Guardar
→ supabase.from('users').update() → getSession() → processLock
→ previousOperation = wrapper_B (NUNCA RESUELVE)
→ FREEZE PERMANENTE
```
La 4D fue 100% correcta: el callback de `onAuthStateChange` ya no tiene queries. Pero el freeze viene de esta cadena completamente diferente, que ocurre en `getSessionSafe()` de los stores.
---
## Fix — 4 archivos
### Cambio 1: `src/lib/supabase.ts`
Dos cambios:
**A) Aumentar `lockAcquireTimeout` a 30s:**
```ts
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
lock: processLock,
// Aumentado de 5000 (default) a 30000ms.
// Con 5s, un token refresh lento dispara ProcessLockAcquireTimeoutError,
// cuyo handler hace await previousOperation (colgada) → chain bloqueado permanente.
// 30s da margen para refresh en redes lentas o cold starts de Supabase Auth.
lockAcquireTimeout: 30000,
},
})
```
**B) Eliminar `getSessionSafe()`:**
Eliminar TODA la función `getSessionSafe()` — declaración y export. No dejar ni el tipo, ni un stub.
La función tenía un problema de diseño: el `Promise.race([getSession(), timeout])` abandona la Promise de `getSession()` cuando gana el timeout. La Promise abandonada deja una operación en `PROCESS_LOCKS[name]` que bloquea el chain.
### Cambio 2: `src/store/library-store.ts`
Eliminar la línea `await getSessionSafe()` y el import correspondiente:
```ts
// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'
// ELIMINAR esta línea en load():
await getSessionSafe()
```
No reemplazar con nada. Las queries a Supabase ya llaman `getSession()` internamente vía `_getAccessToken()`, que espera `initializePromise` automáticamente. El `Promise.race` de 15s que ya existe es suficiente como safety net general.
### Cambio 3: `src/store/process-store.ts`
Mismo tratamiento — hay 2 usos de `getSessionSafe()` (líneas 41 y 57):
```ts
// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'
// ELIMINAR ambas líneas:
await getSessionSafe() // una en loadProcess(), otra en loadActivities()
```
No reemplazar. Misma razón que en library-store.
### Cambio 4: `src/features/report/useReportData.ts`
Mismo tratamiento — 1 uso:
```ts
// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'
// ELIMINAR la línea:
await getSessionSafe()
```
### Cambio 5: `src/components/ProfileSheet.tsx`
Agregar timeout de 10s + estado de error visual. La red de seguridad: si en algún caso extremo la operación tarde más de 10s, el botón se re-habilita y se muestra un mensaje, en lugar de quedar congelado indefinidamente.
```tsx
const [saveError, setSaveError] = useState<string | null>(null)
useEffect(() => {
if (open && user) {
setName(user.name)
setCompany(user.company ?? '')
setSaveError(null) // ← limpiar error al abrir
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
async function handleSave() {
if (!user || !name.trim()) return
setSaving(true)
setSaveError(null)
try {
const savePromise = supabase
.from('users')
.update({ name: name.trim(), company: company.trim() || null })
.eq('id', user.id)
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('La operación tardó demasiado. Revisá tu conexión e intentá de nuevo.')),
10_000
)
)
const { error } = await Promise.race([savePromise, timeoutPromise])
if (error) throw error
updateUser({ name: name.trim(), company: company.trim() || undefined })
onOpenChange(false)
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Error al guardar')
} finally {
setSaving(false)
}
}
```
En el JSX del Sheet, mostrar el error si existe (después del bloque de inputs, antes del footer):
```tsx
{saveError && (
<p className="text-xs text-destructive mt-2">{saveError}</p>
)}
```
---
## Verificación — qué buscar en el código fuente de Supabase
Si querés confirmar antes de implementar, estos son los puntos exactos a verificar en `node_modules/@supabase/auth-js/dist/module/`:
- `locks.js`: función `processLock` — el `catch (e instanceof LockAcquireTimeoutError)` hace `await previousOperation` → si `previousOperation` nunca resuelve, el chain queda bloqueado
- `GoTrueClient.js` línea ~25: `lockAcquireTimeout: 5000` — el default que se pisa con el cambio en `createClient()`
- `GoTrueClient.js` línea ~2239: `getSession()` usa `this.lockAcquireTimeout` (no -1)
---
## Qué NO entra
- Cambios en `AuthContext.tsx` o `SupabaseAuthService.ts` (la 4D fue correcta)
- Cambios en el sistema de simulación, heatmap, o ComparisonTable
- Ninguna feature nueva
- No agregar nuevos `getSessionSafe()` equivalentes en ningún lado
---
## Orden de ejecución
1. `src/lib/supabase.ts`: agregar `lockAcquireTimeout: 30000`, eliminar `getSessionSafe()`
2. `src/store/library-store.ts`: eliminar import + `await getSessionSafe()`
3. `src/store/process-store.ts`: eliminar import + ambos `await getSessionSafe()`
4. `src/features/report/useReportData.ts`: eliminar import + `await getSessionSafe()`
5. `src/components/ProfileSheet.tsx`: agregar `saveError` state + timeout 10s + UI
6. `npm run build` — debe dar 0 errores TypeScript
7. `npm run test` — 528+ Vitest verdes
8. `git push origin main` — OBLIGATORIO antes de validar en producción
9. Commit: `fix(sprint-5/etapa-4e): lockAcquireTimeout 30s + eliminar getSessionSafe — corta freeze en guardar perfil`
---
## Criterios de validación
- [ ] Guardar nombre en ProfileSheet → app NO se congela → nombre actualizado en dropdown
- [ ] Guardar empresa → app NO se congela → empresa aparece en dropdown
- [ ] Cmd+R después de guardar → valores persisten (Supabase)
- [ ] Biblioteca carga correctamente después de eliminar `getSessionSafe()` de library-store
- [ ] Workspace carga correctamente después de eliminar `getSessionSafe()` de process-store
- [ ] Reporte carga correctamente después de eliminar `getSessionSafe()` de useReportData
- [ ] Si save tarda > 10s → botón se re-habilita → mensaje de error visible (no freeze)
- [ ] `npm run build` limpio
- [ ] `npm run test` — 528+ Vitest verdes
- [ ] **`git push origin main` ejecutado** antes de validar en producción
- [ ] **Validación visual del director** antes del OK formal

View File

@@ -0,0 +1,224 @@
# Sprint 5 — Etapa 4F: Fix definitivo save de perfil + ETAPA_4E_PROMPT pendiente
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Supabase v2.106.2.
Esta etapa resuelve DOS issues simultáneos:
1. **Save de perfil produce "sin llamadas de red + sin mensajes en consola"** al hacer click en Guardar
2. **Los Cambios 1-4 del ETAPA_4E_PROMPT.md nunca fueron aplicados al código** — solo se commitió el archivo del prompt
---
## Estado actual del código (ya verificado — no requiere re-lectura inicial)
- `src/components/ProfileSheet.tsx`: ya usa `fetch()` nativo con token de localStorage. ✅ NO tocar este archivo salvo el Cambio 3 de abajo.
- `src/lib/supabase.ts`: `getSessionSafe()` sigue presente, `lockAcquireTimeout` NO configurado. ❌
- `src/store/library-store.ts`: sigue con `await getSessionSafe()`. ❌
- `src/store/process-store.ts`: sigue con `await getSessionSafe()` (2 ocurrencias). ❌
- `src/features/report/useReportData.ts`: sigue con `await getSessionSafe()`. ❌
---
## Causa raíz: botón deshabilitado por `name.trim() === ''`
### Diagnóstico
`ProfileSheet.tsx` tiene:
```tsx
<Button onClick={handleSave} disabled={!name.trim() || saving}>
```
"Sin llamadas de red Y sin mensajes en consola" = el botón está deshabilitado. Click en un botón disabled no dispara ningún event handler — comportamiento nativo del browser, sin eventos, sin logs.
El botón está deshabilitado cuando `name.trim() === ''`. El `name` state se inicializa en `useEffect([open])` con `setName(user.name)`.
### Por qué `user.name` es cadena vacía
**En `SupabaseAuthService.buildAuthUser()`:**
```ts
name: supabaseUser.user_metadata?.full_name as string ?? email,
```
El operador `??` solo hace fallback con `null` o `undefined`. NO hace fallback con cadena vacía.
Si Google devuelve `full_name: ""`, entonces `"" ?? email` = `""``user.name = ""`.
**La cadena de propagación:**
```
buildAuthUser() → name = "" (Google devuelve full_name: "")
upsertUserOnLogin() → inserta name="" en tabla users (ignoreDuplicates: true, primer login)
syncProfileFromDB() → lee name="" del DB, lo retorna como ""
AuthContext setUser → profile.name ?? prev.name = "" ?? prev.name = "" ← ?? no hace fallback!
ProfileSheet useEffect → setName("") → name state = "" → botón disabled
```
Mismo patrón en `getUserFromStorage()` del `AuthContext.tsx`.
---
## Cambios a aplicar — 7 archivos
### Cambio 1: `src/auth/SupabaseAuthService.ts`
**A) En `buildAuthUser`**, cambiar `?? email` por `|| email`:
```ts
// ANTES:
name: supabaseUser.user_metadata?.full_name as string ?? email,
// DESPUÉS:
name: (supabaseUser.user_metadata?.full_name as string | undefined) || email,
```
El operador `||` trata `""`, `null` y `undefined` como ausentes, haciendo fallback al email.
**B) En `syncProfileFromDB`**, cambiar `?? undefined` por `|| undefined`:
```ts
// ANTES:
name: (data.name as string | null) ?? undefined as unknown as string,
// DESPUÉS:
name: ((data.name as string | null | undefined) || undefined) as unknown as string,
```
Esto trata tanto `null` como `""` como ausentes (retorna `undefined`), evitando que un nombre vacío en DB se propague al estado del usuario.
### Cambio 2: `src/auth/AuthContext.tsx`
**A) En `getUserFromStorage()`**, mismo fix `|| email`:
```ts
// ANTES:
name: (u.user_metadata?.full_name as string) ?? email,
// DESPUÉS:
name: (u.user_metadata?.full_name as string | undefined) || email,
```
**B) En el `useEffect([user?.id])` IIFE**, cambiar `??` por `||` para que cadena vacía del DB también haga fallback:
```ts
// ANTES:
? { ...prev, company: profile.company, name: profile.name ?? prev.name }
// DESPUÉS:
? { ...prev, company: profile.company, name: profile.name || prev.name }
```
### Cambio 3: `src/components/ProfileSheet.tsx`
**En `useEffect([open])`**, agregar fallback a email:
```ts
// ANTES:
setName(user.name)
// DESPUÉS:
setName(user.name || user.email || '')
```
Aunque los Cambios 1-2 previenen que `user.name` llegue vacío en sesiones nuevas, este fallback protege a usuarios cuya fila en DB ya tiene `name = ""` de sesiones anteriores. Usando el email como fallback, el campo siempre tiene un valor no-vacío → botón habilitado.
### Cambio 4: `src/lib/supabase.ts`
Dos cambios (del ETAPA_4E_PROMPT.md Cambio 1):
**A) Agregar `lockAcquireTimeout: 30000`:**
```ts
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
lock: processLock,
// Aumentado de 5000 (default) a 30000ms.
// Con 5s, un token refresh lento dispara ProcessLockAcquireTimeoutError,
// cuyo handler hace await previousOperation (colgada) → chain bloqueado permanente.
// 30s da margen para refresh en redes lentas o cold starts de Supabase Auth.
lockAcquireTimeout: 30000,
},
})
```
**B) Eliminar TODA la función `getSessionSafe()`** — declaración y export. No dejar ni stub ni comentario.
La función abandona la Promise de `getSession()` cuando gana el timeout. La Promise abandonada deja una operación en `PROCESS_LOCKS[name]` que bloquea el chain.
### Cambio 5: `src/store/library-store.ts`
```ts
// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'
// ELIMINAR esta línea en load():
await getSessionSafe()
```
No reemplazar con nada. Las queries a Supabase llaman `getSession()` internamente vía `_getAccessToken()` que espera `initializePromise` automáticamente.
### Cambio 6: `src/store/process-store.ts`
```ts
// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'
// ELIMINAR ambas líneas (en loadProcess() y en loadActivities()):
await getSessionSafe()
```
### Cambio 7: `src/features/report/useReportData.ts`
```ts
// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'
// ELIMINAR la línea:
await getSessionSafe()
```
---
## Lo que NO entra
- NO agregar logs de debug
- NO cambiar la estrategia de ProfileSheet (fetch nativo con localStorage token — es correcta, mantener)
- NO implementar el "Cambio 5" del ETAPA_4E_PROMPT.md (ProfileSheet con supabase client + timeout) — ya está mejor con fetch nativo
- NO cambios en simulación, heatmap, ComparisonTable, ni features nuevas
- NO cambios en `AuthContext.tsx` más allá de los especificados en Cambio 2
---
## Orden de ejecución
1. Leer los 7 archivos antes de editar para confirmar estado actual
2. Aplicar Cambio 1 (`SupabaseAuthService.ts`)
3. Aplicar Cambio 2 (`AuthContext.tsx`)
4. Aplicar Cambio 3 (`ProfileSheet.tsx`)
5. Aplicar Cambio 4 (`supabase.ts`)
6. Aplicar Cambio 5 (`library-store.ts`)
7. Aplicar Cambio 6 (`process-store.ts`)
8. Aplicar Cambio 7 (`useReportData.ts`)
9. `npm run build` — debe dar 0 errores TypeScript
10. `npm run test` — todos los Vitest verdes
11. `git add -A && git commit -m "fix(sprint-5/etapa-4f): || fallback en name vacío + eliminar getSessionSafe + lockAcquireTimeout 30s"`
12. `git push origin main`**OBLIGATORIO antes de validar en producción**
---
## Criterios de validación
- [ ] `npm run build` limpio (0 errores TypeScript)
- [ ] `npm run test` — todos los Vitest verdes
- [ ] **`git push origin main` ejecutado**
- [ ] **Validación visual del director antes del OK formal**
Validación funcional (post-deploy, a cargo del director):
- [ ] Guardar nombre en ProfileSheet → app NO se congela → nombre actualizado en dropdown
- [ ] Guardar empresa → app NO se congela → empresa aparece en dropdown
- [ ] Cmd+R después de guardar → valores persisten (Supabase)
- [ ] Biblioteca carga correctamente (sin getSessionSafe)
- [ ] Workspace carga correctamente (sin getSessionSafe)
- [ ] Reporte carga correctamente (sin getSessionSafe)

View File

@@ -0,0 +1,191 @@
# Sprint 5 — Etapa 4G: Revert ProfileSheet a raw fetch
## Contexto
InQ ROI — React 18 + Vite + TypeScript + Supabase v2.
El commit anterior cambió ProfileSheet.tsx para usar `supabase.from('users').update()` en lugar
de raw fetch. Eso introdujo una regresión: el save completa (204) pero la UI se congela
porque `supabase.from().update()` adquiere processLock internamente vía getSession(). Mientras
el lock está ocupado, otras queries Supabase de la página esperan → UI congelada.
La versión anterior (raw fetch con token de localStorage) fue confirmada funcionando: 204, sin
freeze. Esta etapa revierte a esa estrategia.
La función `getAccessToken()` ya no existe en el codebase — hay que recrearla inline en el archivo.
---
## Cambio único: `src/components/ProfileSheet.tsx`
Reemplazar el archivo completo con este contenido exacto:
```tsx
import { useEffect, useState } from 'react'
import { Check } from 'lucide-react'
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { SUPABASE_URL, SUPABASE_ANON_KEY } from '@/lib/supabase'
import { useAuth } from '@/auth/AuthContext'
// Lee el access token directamente de localStorage sin pasar por el cliente Supabase.
// Evita contención con processLock — el save de perfil no necesita refresh de token
// porque es una operación rápida y el token está fresco en sesiones activas.
function getAccessToken(): string | null {
try {
const projectRef = new URL(SUPABASE_URL).hostname.split('.')[0]
const raw = localStorage.getItem(`sb-${projectRef}-auth-token`)
if (!raw) return null
const parsed = JSON.parse(raw) as { access_token?: string }
return parsed.access_token ?? null
} catch {
return null
}
}
interface ProfileSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function ProfileSheet({ open, onOpenChange }: ProfileSheetProps) {
const { user, updateUser } = useAuth()
const [name, setName] = useState('')
const [company, setCompany] = useState('')
useEffect(() => {
if (open && user) {
setName(user.name || user.email || '')
setCompany(user.company ?? '')
}
// Intencional: solo inicializar cuando el sheet se abre, no en cada cambio de `user`
// (evita resetear el formulario mientras el usuario está editando).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
function handleSave() {
if (!user || !name.trim()) return
const savedName = name.trim()
const savedCompany = company.trim() || undefined
// Actualización optimista: aplicar cambios localmente y cerrar el Sheet de inmediato.
// El Sheet nunca queda abierto esperando la respuesta del servidor.
// La modal de Radix bloquea todo el UI mientras está abierta, así que cerramos
// ANTES del fetch para que el usuario pueda seguir trabajando sin demora.
updateUser({ name: savedName, company: savedCompany })
onOpenChange(false)
// Persistir en Supabase en background — fire & forget.
// Usamos raw fetch (no el cliente Supabase) para evitar contención con processLock.
// getSession() interno de supabase.from().update() adquiere processLock y bloquea
// otras queries de la página mientras espera. Raw fetch lee el token de localStorage
// directamente — sin lock, sin overhead de auth.
const token = getAccessToken()
if (!token) return
void fetch(
`${SUPABASE_URL}/rest/v1/users?id=eq.${encodeURIComponent(user.id)}`,
{
method: 'PATCH',
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({ name: savedName, company: savedCompany ?? null }),
signal: AbortSignal.timeout(10000),
}
)
.then((res) => {
if (!res.ok) console.error('[ProfileSheet] Error guardando perfil:', res.status)
})
.catch((err) => {
console.error('[ProfileSheet] Error guardando perfil:', err)
})
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>Editar perfil</SheetTitle>
</SheetHeader>
<div className="space-y-4 mt-6">
<div className="space-y-1.5">
<Label className="text-xs">Nombre</Label>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Tu nombre"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Empresa</Label>
<Input
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="InQuality"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Email</Label>
<Input value={user?.email ?? ''} readOnly className="bg-muted text-muted-foreground cursor-not-allowed" />
</div>
</div>
<SheetFooter className="mt-6 flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button onClick={handleSave} disabled={!name.trim()} className="gap-1.5">
<Check className="h-4 w-4" />
Guardar
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
```
---
## Orden de ejecución
1. Leer `src/components/ProfileSheet.tsx` para confirmar estado actual
2. Reemplazar con el contenido exacto de arriba
3. `npm run build` — debe dar 0 errores TypeScript
4. `npm run test` — todos los Vitest verdes
5. `git add src/components/ProfileSheet.tsx`
6. `git commit -m "fix(sprint-5/etapa-4g): revert ProfileSheet a raw fetch — evita processLock contention"`
7. `git push origin main`
---
## Lo que NO entra
- NO tocar ningún otro archivo
- NO agregar logs de debug
- NO cambios en supabase.ts, AuthContext, stores, ni ningún otro módulo
---
## Criterios de validación
- [ ] `npm run build` limpio (0 errores TypeScript)
- [ ] `npm run test` — Vitest verdes
- [ ] `git push origin main` ejecutado
- [ ] **Validación visual del director antes del OK formal**
Validación funcional (post-deploy, a cargo del director):
- [ ] Abrir ProfileSheet → editar empresa → Guardar → Sheet cierra inmediatamente
- [ ] UI responde inmediatamente después de cerrar (todos los botones funcionan)
- [ ] Refrescar página → cambio persiste

6
docs/.dockerignore Normal file
View File

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

125
docs/CHECKLIST_ENTREGA.md Normal file
View File

@@ -0,0 +1,125 @@
# Checklist de entrega — InQ ROI
> Claude Code ejecuta este checklist ANTES de reportar una etapa como completada.
> El director ejecuta la sección "Validación visual" para el OK formal.
---
## Tier 1 — Automatizado (bloquea entrega si falla)
Correr en este orden. Si cualquier comando falla, no entregar — corregir primero.
```bash
npm run build # 0 errores TypeScript, bundle generado
npm run lint # 0 errores ESLint
npm run test # todos los tests Vitest verdes (reportar N/N)
npx playwright test # todos los E2E verdes (reportar N/N)
```
Reportar en el mensaje de entrega:
- Build: ✅ limpio / ❌ N errores
- Lint: ✅ limpio / ❌ N warnings/errors
- Tests unitarios: ✅ N/N verdes
- Tests E2E: ✅ N/N verdes
**Nota sobre E2E con auth:** los specs que requieren sesión usan `test.use({ storageState: 'tests/e2e/setup/auth-state.json' })`. Ese archivo lo genera el `globalSetup` (`tests/e2e/setup/auth-setup.ts`) automáticamente al correr `npx playwright test`, usando el usuario de test (`TEST_USER_EMAIL`/`TEST_USER_PASSWORD` en `.env.test`, gitignored). Si `.env.test` no existe, el setup avisa por consola y los specs con auth fallan — no es necesario ningún paso manual previo más allá de tener `.env.test` configurado una vez.
---
## Tier 2 — Deploy check (post-deploy en inq-roi.inqualityhq.com)
Abrir DevTools → Network tab. Realizar estas acciones y verificar:
| Acción | Verificar |
|--------|-----------|
| Cargar biblioteca (`/`) | Sin 500, sin 406 en `simulations` |
| Abrir cualquier proceso en workspace | Sin 500, `activities` y `gateways` con 200 |
| Abrir catálogo de recursos (`/recursos`) | Sin 500, tabla carga con datos |
| Exportar PDF (cualquier proceso simulado) | Sin errores en consola, archivo descargado |
---
## Tier 3 — Validación visual (director)
> Esta sección la completa el director. Claude Code NO puede aprobarla.
### Siempre verificar
- [ ] Header muestra avatar del usuario logueado
- [ ] Ningún texto dice "Process Cost Platform" (nombre viejo)
- [ ] Marca "InQ" escrita correctamente (no INQ, no Inq)
- [ ] Botones primarios en naranja `#F59845`
### Por área (verificar si la etapa tocó esa área)
**Biblioteca**
- [ ] Procesos cargan sin spinner infinito
- [ ] "Actualizado por [nombre]" visible en tarjetas
- [ ] Toggle Todos/Míos funciona
**Workspace**
- [ ] BPMN canvas renderiza el diagrama
- [ ] ActivityPanel abre al hacer click en una actividad
- [ ] ResourcesPanel muestra recursos usados en el proceso
**Catálogo de recursos**
- [ ] Tabla muestra recursos con badges de tipo coloreados
- [ ] Búsqueda por nombre funciona
- [ ] Filtro por tipo funciona
- [ ] Crear/editar recurso guarda en Supabase y aparece en la lista
- [ ] Recurso con costo `0.037` muestra `$0.037` (no `$0.04`)
**Auth**
- [ ] Login muestra selector de cuentas de Google
- [ ] Logout redirige a `/login`
- [ ] Sin sesión, cualquier ruta protegida redirige a `/login`
**PDF** (si la etapa tocó PDF)
- [ ] Footer dice "Powered by InQuality"
- [ ] Sin caracteres rotos ni encodings incorrectos
---
## Tier 4 — Integridad de datos (si la etapa tocó schema o repos)
Correr en Supabase Studio SQL Editor:
```sql
-- Sin procesos huérfanos (sin owner)
SELECT COUNT(*) FROM processes WHERE owner_id IS NULL; -- debe ser 0
-- Sin actividades huérfanas
SELECT COUNT(*) FROM activities a
LEFT JOIN processes p ON p.id = a.process_id
WHERE p.id IS NULL; -- debe ser 0
-- Sin recursos con tipo inválido
SELECT DISTINCT type FROM resources;
-- debe mostrar solo: person, role, system, equipment, supply
```
---
## Nota sobre regresiones de PDF
Los tests E2E de PDFs (`etapa-4-pdfs.spec.ts` etc.) son la red de contención para regresiones de PDF.
Si se tocó cualquier cosa de exportación o cálculos, correr específicamente:
```bash
npx playwright test tests/e2e/export-pdf.spec.ts tests/e2e/export-roi.spec.ts
```
---
## Setup del usuario de test E2E (referencia, una sola vez por entorno)
1. Supabase Studio → Authentication → Add user (email/password, NO Google OAuth)
2. SQL Editor:
```sql
insert into public.users (id, name, platform_role)
values ('<uuid del usuario creado>', 'Nombre Test E2E', 'platform_admin');
```
3. Completar `.env.test` (gitignored, ver `.env.example`):
```
TEST_USER_EMAIL=...
TEST_USER_PASSWORD=...
```
4. `npx playwright test` genera `tests/e2e/setup/auth-state.json` automáticamente en cada corrida (también gitignored).

25
nginx.conf Normal file
View File

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

687
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
@@ -27,7 +28,6 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"d3": "^7.9.0", "d3": "^7.9.0",
"dexie": "^4.4.2",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.6", "echarts-for-react": "^3.0.6",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
@@ -1962,6 +1962,182 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.18.tgz",
"integrity": "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-menu": "2.1.18",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/primitive": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
"integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
"license": "MIT"
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
"integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz",
"integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-id": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz",
"integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz",
"integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.3.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz",
"integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
"integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.3",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz",
"integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
"integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": { "node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
@@ -2066,6 +2242,509 @@
} }
} }
}, },
"node_modules/@radix-ui/react-menu": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.18.tgz",
"integrity": "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-collection": "1.1.10",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.13",
"@radix-ui/react-focus-guards": "1.1.4",
"@radix-ui/react-focus-scope": "1.1.10",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-popper": "1.3.1",
"@radix-ui/react-portal": "1.1.12",
"@radix-ui/react-presence": "1.1.6",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-roving-focus": "1.1.13",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-callback-ref": "1.1.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/primitive": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
"integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
"license": "MIT"
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-arrow": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz",
"integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.6"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-collection": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz",
"integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-slot": "1.3.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
"integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz",
"integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-direction": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz",
"integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz",
"integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-escape-keydown": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
"integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz",
"integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-callback-ref": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-id": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz",
"integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz",
"integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.10",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-rect": "1.1.2",
"@radix-ui/react-use-size": "1.1.2",
"@radix-ui/rect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz",
"integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz",
"integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz",
"integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.3.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz",
"integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-collection": "1.1.10",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz",
"integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
"integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
"integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.3",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz",
"integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz",
"integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
"integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-rect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz",
"integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-use-size": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz",
"integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/rect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz",
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
@@ -5220,12 +5899,6 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dexie": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz",
"integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==",
"license": "Apache-2.0"
},
"node_modules/diagram-js": { "node_modules/diagram-js": {
"version": "15.14.0", "version": "15.14.0",
"resolved": "https://registry.npmjs.org/diagram-js/-/diagram-js-15.14.0.tgz", "resolved": "https://registry.npmjs.org/diagram-js/-/diagram-js-15.14.0.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"name": "process-cost-platform", "name": "process-cost-platform",
"private": true, "private": true,
"version": "0.0.0", "version": "0.8.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -14,6 +14,7 @@
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
@@ -32,7 +33,6 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"d3": "^7.9.0", "d3": "^7.9.0",
"dexie": "^4.4.2",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.6", "echarts-for-react": "^3.0.6",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",

View File

@@ -2,6 +2,8 @@ import { defineConfig } from '@playwright/test'
export default defineConfig({ export default defineConfig({
testDir: './tests/e2e', testDir: './tests/e2e',
testIgnore: '**/setup/**',
globalSetup: './tests/e2e/setup/auth-setup.ts',
timeout: 180_000, timeout: 180_000,
use: { use: {
baseURL: 'http://localhost:4173', baseURL: 'http://localhost:4173',

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,202 @@
# Sprint 4 — Etapa 6: Identidad en UI — header con usuario + "actualizado por"
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind.
Etapas 15B completadas: auth Google, todos los repos en Supabase, RLS granular, UI de visibilidad en biblioteca, fix de Web Locks.
Esta etapa agrega presencia del usuario en la UI:
1. **AppHeader** con avatar, nombre y logout
2. **"Actualizado por [nombre]"** en cada tarjeta de proceso de la biblioteca
No hay cambios en PDF, simulación, workspace ni base de datos.
---
## Estado actual
| Elemento | Estado |
|---|---|
| `AppHeader.tsx` | Estático — solo logo + texto "InQ ROI". No usa `useAuth` |
| Tarjeta de proceso | Tiene timestamp relativo (`updatedAt`) pero sin nombre de persona |
| `updated_by` en `fromRow` | Se escribe en DB pero `fromRow` lo descarta — no llega al tipo `Process` |
| Resolución UUID → nombre | No existe. `ownerId` es UUID crudo, nunca se resuelve a nombre |
---
## Alcance estricto — qué entra en esta etapa
### 1. AppHeader — usuario logueado + logout
Modificar `src/components/AppHeader.tsx` para consumir `useAuth()` y mostrar en el extremo derecho del header:
**Versión mínima:**
- Nombre del usuario (`user.name`) y botón "Cerrar sesión" que llama `authService.signOut()`
**Versión con wow (implementar esta):**
- Avatar circular con la foto del usuario (`user.avatarUrl`) o iniciales si no hay foto, usando el color naranja InQ `#F59845` como fondo de fallback
- Al hacer click en el avatar, despliega un pequeño dropdown con:
- Nombre completo + email del usuario
- Opción "Cerrar sesión" con ícono `LogOut` de Lucide
El dropdown debe cerrarse al hacer click fuera. Usar el componente `DropdownMenu` de shadcn/ui.
**Restricción de marca:** el header usa el gradiente `#F59845 → #ED3E8F` como fondo. El dropdown debe tener fondo blanco/neutro (no gradiente). El texto del nombre en el header (si se muestra fuera del dropdown) va en blanco.
---
### 2. Tipo `Process` — agregar `updatedBy` + resolver nombres en query
#### a) Actualizar `src/domain/types.ts`
Agregar al final de la interfaz `Process`:
```typescript
export interface Process {
// ... campos existentes sin cambios ...
updatedBy: string // 🆕 Sprint 4 — UUID del usuario que hizo el último update
}
```
#### b) Actualizar `src/persistence/supabase/process-repo.ts`
**En `fromRow`:** agregar mapeo del campo:
```typescript
updatedBy: (row.updated_by as string) ?? '',
```
**En `getAll()`:** hacer un JOIN con la tabla `users` para traer nombres en una sola query (evitar N+1):
```typescript
async getAll(): Promise<ProcessWithUserNames[]> {
await supabase.auth.getSession() // barrera de sincronización (fix Etapa 5B)
const { data, error } = await supabase
.from('processes')
.select(`
*,
owner:users!owner_id(id, name, avatar_url),
updater:users!updated_by(id, name)
`)
.order('updated_at', { ascending: false })
if (error) throw error
return (data ?? []).map(fromRowWithUsers)
}
```
#### c) Tipo extendido para display — NO contaminar el dominio
Definir en `src/persistence/supabase/process-repo.ts` (no en `domain/types.ts`):
```typescript
// Solo para display — no es parte del dominio puro
export interface ProcessWithUserNames extends Process {
ownerName: string
ownerAvatarUrl?: string
updaterName: string
}
```
El dominio `Process` mantiene solo `ownerId` y `updatedBy` (UUIDs). La resolución a nombres es responsabilidad del repositorio, no del dominio.
**`fromRowWithUsers`**: función nueva que mapea el resultado del JOIN:
```typescript
function fromRowWithUsers(row: ProcessRowWithJoins): ProcessWithUserNames {
return {
...fromRow(row),
ownerName: (row.owner as { name: string } | null)?.name ?? 'Desconocido',
ownerAvatarUrl: (row.owner as { avatar_url: string } | null)?.avatar_url ?? undefined,
updaterName: (row.updater as { name: string } | null)?.name ?? 'Desconocido',
}
}
```
Si el JOIN falla por alguna razón (usuario eliminado, campo nulo), el fallback es `'Desconocido'` — no crashear.
---
### 3. LibraryPage — "Actualizado por [nombre]" en tarjetas
En `src/features/library/LibraryPage.tsx`:
**Cambiar** el store/hook que provee los procesos para que use `ProcessWithUserNames` en lugar de `Process` — el tipo ya viene resuelto del repositorio.
**En la `ProcessCard`**, reemplazar la línea actual:
```tsx
"Modificado {relativeDate(process.updatedAt)}"
```
Por:
```tsx
"Actualizado por {process.updaterName} · {relativeDate(process.updatedAt)}"
```
**Versión con wow (implementar esta):**
- Si el `updaterName` coincide con el usuario actual (`updaterName === currentUser.name`), mostrar "por vos" en lugar del nombre
- Tooltip al pasar por encima que muestra la fecha completa (no solo relativa)
---
### 4. Workspace — "Actualizado por" en header del proceso (opcional, si cabe en scope)
Si la estructura del workspace ya tiene un lugar para metadatos del proceso (nombre del proceso, cliente), agregar debajo del nombre:
```
Actualizado por [nombre] · hace 2 días
```
Si esto requiere cambios en el store o en cómo WorkspacePage carga el proceso, diferirlo — no es bloqueante para el sprint.
---
## Qué NO entra en esta etapa
- Panel de perfil de usuario (editar nombre, foto)
- Gestión de grupos de usuarios
- ResourcesPanel: UX de catálogo global → Etapa 7
- Cambios en PDF
- Cambios en simulación
- Tests de integración de auth → Etapa 8
---
## Orden de ejecución
1. Actualizar `src/domain/types.ts` — agregar `updatedBy: string` a `Process`
2. Actualizar `src/persistence/supabase/process-repo.ts`:
- `fromRow`: mapear `updated_by`
- Agregar `ProcessWithUserNames` interface
- Agregar `fromRowWithUsers` function
- Modificar `getAll()` para usar JOIN
3. Actualizar el store/hook de biblioteca para propagar `ProcessWithUserNames`
4. Modificar `ProcessCard` en `LibraryPage.tsx` — "Actualizado por [nombre]"
5. Modificar `AppHeader.tsx` — avatar + dropdown con logout
6. (Opcional) "Actualizado por" en workspace si es < 30 min de trabajo
7. `npm run build` — debe pasar sin errores TypeScript
8. `npm run test` — debe mantener 521+ tests verdes
9. Commit: `feat(sprint-4/etapa-6): identidad en UI — header con usuario + actualizado por`
---
## Criterios de validación
- [ ] AppHeader muestra avatar del usuario logueado (o iniciales con fondo naranja si no hay foto)
- [ ] Click en avatar despliega dropdown con nombre, email y "Cerrar sesión"
- [ ] "Cerrar sesión" funciona — redirige a `/login`
- [ ] Tarjetas de proceso muestran "Actualizado por [nombre]"
- [ ] Si el updater es el usuario actual, muestra "por vos"
- [ ] El JOIN en `getAll()` no rompe el build (TypeScript sin `any` injustificado)
- [ ] `Process.updatedBy` existe en `types.ts`
- [ ] `fromRow` mapea `updated_by`
- [ ] `npm run build` limpio
- [ ] 521+ tests verdes
- [ ] **Validación visual del director** — ver avatar en header y nombres en tarjetas antes del OK formal
---
## Nota sobre el JOIN de Supabase
El JOIN `users!owner_id(...)` usa la sintaxis de PostgREST para FK explícita. Si la FK no está declarada en el schema de Supabase (aunque exista en PostgreSQL), el join puede fallar. Verificar en Supabase Dashboard → Table Editor → processes que las FK `owner_id → users.id` y `updated_by → users.id` aparecen como relaciones. Si no aparecen, agregar al schema o usar un select con `.eq()` fallback.
Alternativa si el JOIN falla: hacer `getAll()` + un `supabase.from('users').select('id, name, avatar_url')` separado y mapear en memoria. Menos elegante pero más robusto. Preferir el JOIN pero tener este fallback documentado.

View File

@@ -0,0 +1,202 @@
# Sprint 4 — Etapa 7B: Tres fixes post-validación visual
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind.
Etapa 7 completada con OK formal. Durante la validación visual del director se detectaron tres issues que entran en esta etapa.
---
## Fix 1 — Catálogo con contexto de proceso (Opción A aprobada)
### Problema
Cuando el usuario navega desde el ResourcesPanel del workspace al catálogo (`/recursos`), no hay forma de volver al proceso de origen ni de indicar que un recurso está disponible para usar en ese proceso.
### Solución
**Paso 1: ResourcesPanel — pasar contexto en la URL**
En `src/features/workspace/ResourcesPanel.tsx`, el link "Ver catálogo completo" debe incluir el `processId` del proceso actual como query param:
```typescript
// Antes:
navigate({ to: '/recursos' })
// Después:
navigate({ to: '/recursos', search: { fromProcess: currentProcess.id } })
```
**Paso 2: ResourceCatalogPage — detectar contexto y mostrar UI de retorno**
En `src/features/resources/ResourceCatalogPage.tsx`:
Leer el query param `fromProcess` (puede ser undefined si el usuario navegó directamente).
Si `fromProcess` está presente:
1. **Banner contextual** en la parte superior de la página (antes de la barra de herramientas), con fondo suave naranja InQ:
```
[←] Estás viendo el catálogo desde el proceso "[nombre del proceso]"
Seleccioná un recurso y usalo en una actividad al volver al workspace.
[← Volver al proceso]
```
- El nombre del proceso se resuelve buscando `fromProcess` en el store o con una query puntual a Supabase
- El botón "← Volver al proceso" navega de vuelta a `/workspace/:processId`
2. **Botón "← Usar en proceso" en cada fila de la tabla** (columna Acciones, junto a editar/eliminar):
- Solo visible cuando `fromProcess` está presente
- Al hacer click: navega a `/workspace/:processId`
- No hace ninguna asignación automática — la asignación sigue siendo en ActivityPanel
- Tooltip: "Volver al workspace para asignar este recurso a una actividad"
**Colores del banner:**
- Fondo: `#FEF3E7` (naranja InQ muy suave)
- Borde izquierdo: `#F59845` (naranja InQ)
- Texto secundario: `var(--color-text-secondary)`
- Botón "Volver": estilo secundario (borde, sin relleno)
**Si `fromProcess` no está presente** (usuario llegó al catálogo directamente): sin banner, sin botón extra. La página funciona exactamente igual que en Etapa 7.
---
## Fix 2 — Redondeo del costo por hora para valores pequeños
### Problema
Un recurso con costo `$0.037/h` se muestra como `$0.04` — pérdida de información para recursos de bajo costo unitario (licencias de software, servicios cloud por hora, etc.).
### Solución
Crear o actualizar la función de formateo de costo por hora para que use precisión dinámica. Ubicar en `src/lib/format.ts` (o donde esté la función `formatCurrency` / `formatCostPerHour`):
```typescript
export function formatCostPerHour(value: number, currency = 'USD'): string {
if (value === 0) return '$0'
// Determinar decimales según magnitud
let decimals: number
if (value >= 100) decimals = 0 // $150/h → $150
else if (value >= 10) decimals = 1 // $12.5/h → $12.5
else if (value >= 1) decimals = 2 // $1.23/h → $1.23
else if (value >= 0.1) decimals = 3 // $0.123/h → $0.123
else decimals = 4 // $0.0123/h → $0.0123
return `$${value.toFixed(decimals)}`
}
```
Aplicar esta función en todos los lugares donde se muestra el costo por hora de un recurso:
- Tabla del catálogo `/recursos`
- ResourcesPanel (lista de recursos usados en el proceso)
- ActivityPanel (selector de recursos y fila de recurso asignado)
- Cualquier otro lugar donde aparezca `costPerHour`
**Verificación:** ingresar un recurso con costo `0.037` y confirmar que se muestra `$0.037` (no `$0.04`).
---
## Fix 3 — Google OAuth: forzar selector de cuenta
### Problema
Al hacer click en "Iniciar sesión con Google", el navegador usa directamente la sesión anterior sin mostrar el selector de cuentas de Google. Impide cambiar de cuenta o seleccionar entre múltiples cuentas del navegador.
### Solución
En `src/auth/SupabaseAuthService.ts`, en el método `signInWithGoogle()`, agregar `prompt: 'select_account'` en los `queryParams`:
```typescript
async signInWithGoogle(): Promise<void> {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
prompt: 'select_account', // 🆕 fuerza el selector de cuentas de Google
},
},
})
if (error) throw error
}
```
`prompt: 'select_account'` es un parámetro estándar de OAuth 2.0 / OpenID Connect que instruye a Google a siempre mostrar el selector de cuentas, incluso si hay una sesión activa.
---
## Qué NO entra en esta etapa
- Asignación automática de recursos desde el catálogo (eso sería Opción B — diferida)
- Cambios en PDF, simulación o workspace salvo los indicados
- Ningún cambio de schema en Supabase
---
## Fix 4 — simulations: 406 por `.single()` sin filas + re-fetch en loop
### Problema
En el network tab se observan múltiples requests con 406 Not Acceptable:
```
GET simulations?select=*&process_id=eq.{id}&order=executed_at.desc&limit=1 → 406
```
**Causa:** supabase-js v2 `.single()` envía `Accept: application/vnd.pgrst.object+json`, que exige exactamente 1 fila. Si el proceso no tiene simulaciones aún (0 filas), PostgREST responde 406. El mismo query aparece ~6 veces para los mismos `processId` — señal de un `useEffect` re-fetching por dependencia incorrecta.
### Solución
**Parte A — Fix del 406:** en `src/persistence/supabase/simulation-repo.ts`, en el método que busca la última simulación por proceso, cambiar `.single()` por `.maybeSingle()`:
```typescript
// Antes:
const { data, error } = await supabase
.from('simulations')
.select('*')
.eq('process_id', processId)
.order('executed_at', { ascending: false })
.limit(1)
.single() // ← lanza 406 cuando no hay filas
// Después:
const { data, error } = await supabase
.from('simulations')
.select('*')
.eq('process_id', processId)
.order('executed_at', { ascending: false })
.limit(1)
.maybeSingle() // ← retorna null sin error cuando no hay filas
```
El caller debe manejar `data === null` como "sin simulación" (no como error).
**Parte B — Fix del re-fetch en loop:** localizar el `useEffect` que dispara esta query (probablemente en `LibraryPage.tsx` o en un hook de simulación). Revisar que sus dependencias no incluyan un objeto o array que se re-crea en cada render (patrón clásico de loop infinito). Si el array de dependencias contiene `processes` (que es un array nuevo en cada render del store), estabilizarlo con `useMemo` o extraer solo los IDs que realmente cambian.
---
## Orden de ejecución
1. Fix 3 — `SupabaseAuthService.signInWithGoogle()` (1 línea)
2. Fix 4 — `.maybeSingle()` en simulation-repo + revisar re-fetch loop
3. Fix 2 — función `formatCostPerHour` con precisión dinámica + aplicar en todos los contextos
4. Fix 1 — query param en ResourcesPanel + banner + botón en ResourceCatalogPage
5. `npm run build` — debe pasar sin errores TypeScript
6. `npm run test` — debe mantener 521+ tests verdes
7. Commit: `fix(sprint-4/etapa-7b): selector cuenta Google + redondeo costo/hora + contexto catálogo + simulations maybeSingle`
---
## Criterios de validación
- [ ] Login muestra el selector de cuentas de Google (no auto-login)
- [ ] Recurso con costo `0.037` se muestra como `$0.037`, no `$0.04`
- [ ] Recurso con costo `45` se muestra como `$45`, no `$45.00` (sin decimales innecesarios)
- [ ] Navegar desde ResourcesPanel → catálogo muestra el banner con nombre del proceso
- [ ] Botón "← Usar en proceso" aparece en cada fila cuando hay contexto de proceso
- [ ] Click en "← Usar en proceso" navega a `/workspace/:processId`
- [ ] Navegar a `/recursos` directamente (sin `fromProcess`) no muestra banner ni botón extra
- [ ] Network tab sin 406 en requests de `simulations`
- [ ] Network tab: el query de simulaciones no se repite en loop para el mismo `processId`
- [ ] Build limpio sin errores TypeScript
- [ ] 521+ tests verdes
- [ ] **Validación visual del director** antes del OK formal

View File

@@ -0,0 +1,202 @@
# Sprint 4 — Etapa 7: ResourcesPanel — UX de catálogo global
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind.
Etapas 16 completadas. El backend de recursos ya es global desde Etapa 3: `resource-repo.ts` hace `getAll()` sin filtro, el upsert no envía `processId`, y la tabla `resources` en Supabase no tiene columna `process_id`.
Lo que falta es UX: el `ResourcesPanel` todavía muestra todos los recursos sin filtrar (con el header "Recursos del proceso" que ya no corresponde) y no existe página de catálogo.
**Este prompt tiene un mockup aprobado como contrato visual. Claude Code lo replica sin interpretar libremente.**
---
## Mockup aprobado — descripción exacta
### Vista 1: ResourcesPanel en workspace (panel lateral derecho)
**Header del panel:**
- Título: "Recursos" (no "del proceso")
- Botón naranja `#F59845` con ícono `+` y texto "Nuevo" — abre un Sheet/Dialog para crear recurso en el catálogo global
**Cuerpo del panel:**
- Subheader: "Usados en este proceso" + cuenta `(N)`
- Lista de recursos que tienen al menos una asignación en las actividades del proceso actual
- Cada ítem: avatar circular con ícono del tipo (ver iconos y colores más abajo) + nombre del recurso + costo/hora a la derecha
- Segunda línea: tipo en texto + "N actividades" (contar cuántas actividades de este proceso usan este recurso)
- Si no hay recursos asignados: estado vacío con mensaje "Ningún recurso asignado a actividades aún"
**Footer del panel:**
- Texto de contexto: "Recursos del catálogo global del equipo"
- Botón secundario ancho completo: ícono `external-link` + "Ver catálogo completo" → navega a `/recursos`
**La lista de recursos "usados en este proceso" se deriva así:**
```typescript
const usedResourceIds = new Set(
currentProcess.activities
.flatMap(a => a.assignedResources ?? [])
.map(ar => ar.resourceId)
)
const usedResources = resources.filter(r => usedResourceIds.has(r.id))
const usageCount = (resourceId: string) =>
currentProcess.activities.filter(
a => (a.assignedResources ?? []).some(ar => ar.resourceId === resourceId)
).length
```
---
### Vista 2: Página catálogo `/recursos`
**Header de la página:**
- Título: "Catálogo de recursos"
- Subtítulo con ícono de equipo: "Compartido con todo el equipo · N recursos"
- Botón naranja `#F59845`: "+ Nuevo recurso" → abre Sheet/Dialog de creación
**Barra de herramientas:**
- Input de búsqueda (filtra por nombre en tiempo real, client-side)
- Select de tipo (filtra por tipo: "Todos los tipos" + los 5 tipos)
**Tabla:**
| Recurso | Tipo | Costo/h | Creado por | Acciones |
|---------|------|---------|-----------|---------|
| [avatar + nombre] | [badge] | [mono] | [nombre] | [edit] [delete] |
- Botones de acción: editar (abre Sheet con form pre-poblado) y eliminar (con confirmación)
- "Creado por" se resuelve desde el JOIN con `users` (igual que en LibraryPage — mismo patrón de Etapa 6)
---
## Tipos de recurso — valores definitivos
**Los únicos valores válidos son:** `person | role | system | equipment | supply`
Estos son los valores del schema real (migración 005). Actualizar `ResourceType` en `domain/types.ts`:
```typescript
export type ResourceType = 'person' | 'role' | 'system' | 'equipment' | 'supply'
```
**Labels en español para la UI:**
```typescript
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
person: 'Persona',
role: 'Rol',
system: 'Sistema',
equipment: 'Equipo',
supply: 'Insumo',
}
```
**Íconos y colores por tipo (Lucide React / Tabler):**
```typescript
// Usando Lucide React (ya en el stack):
person: User fondo #FEF3E7, ícono #F59845
role: Briefcase fondo #FEF3E7, ícono #D97706
system: Server fondo #EEF2FF, ícono #6366F1
equipment: Wrench fondo #F0FDF4, ícono #16A34A
supply: Package fondo #F1F5F9, ícono #64748B
```
---
## Alcance estricto — qué entra en esta etapa
### 1. Limpieza de tipos
En `src/domain/types.ts`:
- Actualizar `ResourceType` a los 5 valores definitivos (ver arriba)
- Eliminar `processId?: UUID` de la interfaz `Resource` — es un campo vestigial sin uso. Ya no se mapea ni se envía
En `src/persistence/supabase/resource-repo.ts`:
- Eliminar cualquier referencia a `processId` si existe en `fromRow` o `toRow`
### 2. Página de catálogo — nueva ruta `/recursos`
Crear `src/features/resources/ResourceCatalogPage.tsx` con:
- Header + búsqueda + filtro por tipo + tabla (ver mockup)
- Sheet/Dialog compartido para crear y editar recursos (reutilizar el mismo componente para ambas acciones)
- Confirmación antes de eliminar (no eliminar recursos que están asignados a actividades — mostrar error explicativo si hay asignaciones activas)
**JOIN con users para "Creado por"** (mismo patrón que Etapa 6):
```typescript
// En resource-repo.ts, método getAll():
const { data } = await supabase
.from('resources')
.select('*, creator:users!created_by(id, name)')
.order('name', { ascending: true })
```
Agregar tipo extendido `ResourceWithCreator` en `resource-repo.ts` (igual que `ProcessWithUserNames` en Etapa 6).
**Ruta nueva en TanStack Router:** `src/router.tsx` o donde estén definidas las rutas.
### 3. Navegación al catálogo
Agregar acceso a `/recursos` desde la app. Opciones (elegir la más limpia según la estructura de nav existente):
- Link en AppHeader junto al logo/título
- O link desde el botón "Ver catálogo completo" del ResourcesPanel (este ya está en el mockup)
- No agregar ítem de navegación lateral si no existe nav lateral — el acceso desde el panel es suficiente para Sprint 4
### 4. ResourcesPanel — redesign
Reemplazar el cuerpo actual de `src/features/workspace/ResourcesPanel.tsx` según el mockup aprobado:
- Lista filtrada "usados en este proceso" (ver derivación arriba)
- Botón "Nuevo" → Sheet/Dialog de creación (el mismo componente de la página de catálogo)
- Link "Ver catálogo completo" → navega a `/recursos`
- El CRUD completo (editar, eliminar, listar todos) vive en `/recursos`, no en el panel del workspace
### 5. ActivityPanel — sin cambios
El dropdown de asignación de recursos ya funciona correctamente con el catálogo global. No tocar.
---
## Qué NO entra en esta etapa
- Gestión de grupos de usuarios
- Permisos por recurso (los recursos son siempre visibles para todos los autenticados)
- Import/export de catálogo
- Historial de uso de un recurso (cuántos procesos lo usan)
- Cambios en PDF, simulación o workspace salvo ResourcesPanel
- Tests de integración de auth → Etapa 8
---
## Orden de ejecución
1. Actualizar `ResourceType` en `domain/types.ts` + eliminar `processId?` de `Resource`
2. Limpiar `resource-repo.ts` (eliminar processId, agregar JOIN con users)
3. Crear `src/features/resources/ResourceCatalogPage.tsx`
4. Registrar la ruta `/recursos` en el router
5. Redesign de `ResourcesPanel.tsx` (mockup como ground truth)
6. `npm run build` — debe pasar sin errores TypeScript
7. `npm run test` — debe mantener 521+ tests verdes
8. Commit: `feat(sprint-4/etapa-7): catálogo global de recursos + redesign ResourcesPanel`
---
## Criterios de validación
- [ ] `ResourceType` tiene exactamente `'person' | 'role' | 'system' | 'equipment' | 'supply'`
- [ ] `Resource` no tiene campo `processId`
- [ ] Ruta `/recursos` existe y carga la página de catálogo
- [ ] Tabla del catálogo muestra nombre, tipo (badge con color), costo/h, creado por
- [ ] Búsqueda client-side funciona por nombre
- [ ] Filtro por tipo funciona
- [ ] Crear recurso desde catálogo funciona (guarda en Supabase, aparece en la lista)
- [ ] Editar recurso funciona
- [ ] Eliminar recurso funciona (con confirmación)
- [ ] ResourcesPanel en workspace muestra SOLO recursos usados en el proceso actual
- [ ] ResourcesPanel "Ver catálogo completo" navega a `/recursos`
- [ ] ResourcesPanel "Nuevo" crea en el catálogo global
- [ ] ActivityPanel sigue funcionando (asignación de recursos sin cambios)
- [ ] Build limpio sin errores TypeScript
- [ ] 521+ tests verdes
- [ ] **Validación visual del director** — ver ResourcesPanel con lista filtrada y página `/recursos` con tabla antes del OK formal
---
## Nota: Sheet vs Modal para el form de recurso
Usar `Sheet` de shadcn/ui (panel lateral deslizante) en lugar de `Dialog` (modal centrado) — es más consistente con el contexto del workspace donde el espacio es lateral. El Sheet se abre desde ambos lugares: botón "Nuevo" del ResourcesPanel y botón "+ Nuevo recurso" de la página de catálogo. Reutilizar el mismo componente `ResourceFormSheet.tsx` en ambos contextos.

View File

@@ -0,0 +1,318 @@
# Sprint 4 — Etapa 8: Tests de integración + batería de entrega + polish
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind.
Etapas 17B completadas. Sprint 4 introdujo auth (Google OAuth + Supabase), PostgreSQL, RLS, catálogo de recursos, y múltiples páginas nuevas.
**Problema crítico detectado:** todos los tests E2E existentes (15 specs en `tests/e2e/`) corren contra `localhost:4173` sin autenticación. Con Sprint 4, todas las rutas protegidas redirigen a `/login`. Es muy probable que la mayoría de los specs existentes estén fallando o saltando pasos silenciosamente.
Esta etapa tiene tres entregables:
1. **Auditoría y corrección** de los tests E2E existentes
2. **Nuevos tests E2E** para los flujos críticos de Sprint 4
3. **`docs/CHECKLIST_ENTREGA.md`** — protocolo exhaustivo que Claude Code ejecuta antes de entregar cualquier etapa, en cualquier sprint futuro
---
## Parte 1 — Auditoría de tests existentes
### 1a. Correr todos los specs actuales y reportar estado
```bash
npx playwright test --reporter=list 2>&1 | tail -50
```
Clasificar cada spec como:
- **VERDE** — pasa sin cambios
- **ROJO-AUTH** — falla porque la app redirige a `/login` antes de que el test llegue al contenido
- **ROJO-OTRO** — falla por otra razón
### 1b. Fix para specs ROJO-AUTH
Los specs que prueban flujos con datos (PDFs, simulaciones, BPMN) necesitan un mecanismo de bypass de auth para tests.
**Estrategia: mock de sesión vía localStorage en global setup**
Crear `tests/e2e/setup/auth-setup.ts`:
```typescript
import { chromium, FullConfig } from '@playwright/test'
async function globalSetup(config: FullConfig) {
// Usar supabase-js directamente con service role para crear sesión de test
// O: inyectar una sesión pre-generada en localStorage antes de que el test navegue
// Opción A (recomendada si hay usuario email/password en Supabase):
// const { data } = await supabase.auth.signInWithPassword({
// email: process.env.TEST_USER_EMAIL,
// password: process.env.TEST_USER_PASSWORD,
// })
// guardar storageState con la sesión
// Opción B (mock mínimo para tests que no necesitan queries reales):
// Inyectar localStorage key de sesión fake para bypasear el guard de AuthContext
}
```
**Prerequisito para Opción A:** crear un usuario de test en Supabase con email/password (NO Google OAuth) con `platform_role = 'platform_admin'`. Las credenciales van en `.env.test` (ignorado por git) y documentadas en `.env.example`:
```
TEST_USER_EMAIL=test@inquality.com.py
TEST_USER_PASSWORD=...
```
**Si Opción A no es viable ahora:** los specs que prueban PDF/screenshots pueden mockear el store de Zustand directamente (los PDFs no necesitan Supabase para generarse — solo necesitan datos en el store). Revisar si se puede inicializar el store con fixtures sin pasar por la DB.
Documentar la decisión tomada en un comentario al inicio del setup.
### 1c. Specs que prueban solo PDFs/screenshots (sin data de Supabase)
Los specs `etapa-4-pdfs.spec.ts` hasta `etapa-11-polish.spec.ts` y `export-pdf.spec.ts` probablemente cargan BPMNs locales y generan PDFs sin tocar Supabase. Para estos:
- Si el único problema es el redirect a `/login`, agregar el mock de sesión del setup
- Si necesitan datos en el store, cargarlos via fixture antes de navegar
No reescribir la lógica de los tests — solo agregar el mecanismo de auth bypass.
---
## Parte 2 — Nuevos tests E2E para Sprint 4
Crear `tests/e2e/sprint-4-smoke.spec.ts`:
### Tests sin auth (deben pasar sin sesión)
```typescript
test('login page renders — Google button visible', async ({ page }) => {
await page.goto('/login')
await expect(page.getByRole('button', { name: /iniciar sesión con google/i })).toBeVisible()
})
test('protected route redirects to login', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveURL(/\/login/)
})
test('protected route /workspace redirects to login', async ({ page }) => {
await page.goto('/workspace/fake-id')
await expect(page).toHaveURL(/\/login/)
})
test('protected route /recursos redirects to login', async ({ page }) => {
await page.goto('/recursos')
await expect(page).toHaveURL(/\/login/)
})
```
### Tests con auth (requieren sesión de test)
Usar el fixture de sesión del global setup:
```typescript
test.use({ storageState: 'tests/e2e/setup/auth-state.json' })
test('library loads after auth — no 500 errors', async ({ page }) => {
const errors: string[] = []
page.on('response', r => { if (r.status() >= 500) errors.push(r.url()) })
await page.goto('/')
await page.waitForLoadState('networkidle')
expect(errors).toHaveLength(0)
})
test('library — no 406 errors in simulations queries', async ({ page }) => {
const errors406: string[] = []
page.on('response', r => {
if (r.status() === 406 && r.url().includes('simulations')) errors406.push(r.url())
})
await page.goto('/')
await page.waitForLoadState('networkidle')
expect(errors406).toHaveLength(0)
})
test('catalog page loads at /recursos', async ({ page }) => {
await page.goto('/recursos')
await expect(page.getByRole('heading', { name: /catálogo de recursos/i })).toBeVisible()
})
test('app header shows user avatar', async ({ page }) => {
await page.goto('/')
// El avatar tiene las iniciales del usuario o una imagen
await expect(page.locator('[aria-label*="usuario"]').or(
page.locator('img[alt*="avatar"]')
)).toBeVisible()
})
test('import BPMN — navigates to workspace', async ({ page }) => {
await page.goto('/import')
// Hacer click en "Usar ejemplo" si existe, o subir simple-linear.bpmn
// Verificar que navega al workspace
await expect(page).toHaveURL(/\/workspace\//)
})
```
Agregar solo los tests que sean estables y no flaky. Si un flujo es difícil de automatizar (ej: el import con file upload real), documentarlo como "manual" en el checklist.
---
## Parte 3 — `docs/CHECKLIST_ENTREGA.md`
Crear este archivo. Es el protocolo que Claude Code ejecuta **antes de reportar cualquier etapa como completada**, en este sprint y en todos los futuros.
```markdown
# Checklist de entrega — InQ ROI
> Claude Code ejecuta este checklist ANTES de reportar una etapa como completada.
> El director ejecuta la sección "Validación visual" para el OK formal.
---
## Tier 1 — Automatizado (bloquea entrega si falla)
Correr en este orden. Si cualquier comando falla, no entregar — corregir primero.
\`\`\`bash
npm run build # 0 errores TypeScript, bundle generado
npm run lint # 0 errores ESLint
npm run test # todos los tests Vitest verdes (reportar N/N)
npx playwright test # todos los E2E verdes (reportar N/N)
\`\`\`
Reportar en el mensaje de entrega:
- Build: ✅ limpio / ❌ N errores
- Lint: ✅ limpio / ❌ N warnings/errors
- Tests unitarios: ✅ N/N verdes
- Tests E2E: ✅ N/N verdes
---
## Tier 2 — Deploy check (post-deploy en inq-roi.inqualityhq.com)
Abrir DevTools → Network tab. Realizar estas acciones y verificar:
| Acción | Verificar |
|--------|-----------|
| Cargar biblioteca (`/`) | Sin 500, sin 406 en `simulations` |
| Abrir cualquier proceso en workspace | Sin 500, `activities` y `gateways` con 200 |
| Abrir catálogo de recursos (`/recursos`) | Sin 500, tabla carga con datos |
| Exportar PDF (cualquier proceso simulado) | Sin errores en consola, archivo descargado |
---
## Tier 3 — Validación visual (director)
> Esta sección la completa el director. Claude Code NO puede aprobarla.
### Siempre verificar
- [ ] Header muestra avatar del usuario logueado
- [ ] Ningún texto dice "Process Cost Platform" (nombre viejo)
- [ ] Marca "InQ" escrita correctamente (no INQ, no Inq)
- [ ] Botones primarios en naranja `#F59845`
### Por área (verificar si la etapa tocó esa área)
**Biblioteca**
- [ ] Procesos cargan sin spinner infinito
- [ ] "Actualizado por [nombre]" visible en tarjetas
- [ ] Toggle Todos/Míos funciona
**Workspace**
- [ ] BPMN canvas renderiza el diagrama
- [ ] ActivityPanel abre al hacer click en una actividad
- [ ] ResourcesPanel muestra recursos usados en el proceso
**Catálogo de recursos**
- [ ] Tabla muestra recursos con badges de tipo coloreados
- [ ] Búsqueda por nombre funciona
- [ ] Filtro por tipo funciona
- [ ] Crear/editar recurso guarda en Supabase y aparece en la lista
- [ ] VPS con costo `0.037` muestra `$0.037` (no `$0.04`)
**Auth**
- [ ] Login muestra selector de cuentas de Google
- [ ] Logout redirige a `/login`
- [ ] Sin sesión, cualquier ruta protegida redirige a `/login`
**PDF** (si la etapa tocó PDF)
- [ ] Footer dice "Powered by InQuality"
- [ ] Sin caracteres rotos ni encodings incorrectos
---
## Tier 4 — Integridad de datos (si la etapa tocó schema o repos)
Correr en Supabase Studio SQL Editor:
\`\`\`sql
-- Sin procesos huérfanos (sin owner)
SELECT COUNT(*) FROM processes WHERE owner_id IS NULL; -- debe ser 0
-- Sin actividades huérfanas
SELECT COUNT(*) FROM activities a
LEFT JOIN processes p ON p.id = a.process_id
WHERE p.id IS NULL; -- debe ser 0
-- Sin recursos con tipo inválido
SELECT DISTINCT type FROM resources;
-- debe mostrar solo: person, role, system, equipment, supply
\`\`\`
---
## Nota sobre regresiones de PDF
Los tests E2E de PDFs (`etapa-4-pdfs.spec.ts` etc.) son la red de contención para regresiones de PDF.
Si se tocó cualquier cosa de exportación o cálculos, correr específicamente:
\`\`\`bash
npx playwright test tests/e2e/export-pdf.spec.ts tests/e2e/export-roi.spec.ts
\`\`\`
```
---
## Parte 4 — Polish
Revisar y corregir cualquiera de estos si existe:
- En la biblioteca, si el `OwnershipBadge` todavía tiene texto "Solo vos" / "De otro consultor" en inglés o hardcodeado — corregir a español
- Verificar que el texto de "Compartir con equipo" en el menú ⋮ sigue siendo visible aunque sea no-op (con un tooltip o texto secundario que diga "Disponible próximamente" si el director lo prefiere — **consultar antes de implementar**)
- Revisar que `npm run lint` pasa sin warnings en los archivos nuevos de Sprint 4
---
## Qué NO entra en esta etapa
- Implementación real de `setShared` (diferida — ver OBSERVATIONS.md)
- Keycloak / migración de auth
- Features de producto nuevas
- Cambios en PDF o simulación
---
## Orden de ejecución
1. Correr todos los E2E existentes y clasificar estado (verde/rojo-auth/rojo-otro)
2. Implementar estrategia de auth bypass para tests (Opción A o B según viabilidad)
3. Corregir specs ROJO-AUTH con el nuevo setup
4. Crear `tests/e2e/sprint-4-smoke.spec.ts`
5. Crear `docs/CHECKLIST_ENTREGA.md`
6. Polish (lint warnings, textos)
7. `npm run test` — mantener 521+ tests Vitest verdes
8. `npx playwright test` — reportar estado completo (verde/rojo con razón)
9. Commit: `test(sprint-4/etapa-8): batería E2E Sprint 4 + CHECKLIST_ENTREGA + polish`
---
## Criterios de validación
- [ ] Todos los specs E2E clasificados como verde, rojo-auth, o rojo-otro
- [ ] Specs ROJO-AUTH corregidos con estrategia de auth bypass documentada
- [ ] `tests/e2e/sprint-4-smoke.spec.ts` creado con al menos los 4 tests sin-auth
- [ ] `docs/CHECKLIST_ENTREGA.md` creado con las 4 secciones (Tier 14)
- [ ] `npm run lint` sin warnings en archivos de Sprint 4
- [ ] 521+ tests Vitest verdes
- [ ] E2E: reportar N verdes / M rojos con razón para cada rojo
- [ ] **Validación visual del director** antes del OK formal

249
sprints/sprint-5/BRIEF.md Normal file
View File

@@ -0,0 +1,249 @@
# Sprint 5 — BRIEF
> InQ ROI — Sprint de UX polish + quick wins
> Inicio: 2026-06-19
> Foco: experiencia del usuario, mejoras visuales, features de alto impacto/bajo esfuerzo
---
## Contexto
Sprint 4 fue un refactor arquitectural completo (IndexedDB → Supabase, auth, recursos globales). El producto está estable y en uso interno. Sprint 5 vuelve al rebalanceo normal: **60-70% features de producto, 10-15% infra/deuda técnica**.
Sin features de gran escala (historial de simulaciones, compartir con guests). Solo mejoras que suman experiencia real.
---
## Etapas
### Etapa 1 — Fix crítico: workspace loading
**Problema:** Al navegar desde la biblioteca a un proceso, el workspace se queda cargando indefinidamente. La pantalla solo carga correctamente después de refrescar la página manualmente.
**Diagnóstico a confirmar:** probable race condition entre `useEffect` de carga del proceso y disponibilidad del usuario autenticado. El `processId` llega por route params antes de que el store tenga el usuario listo, y el query a Supabase se dispara con un contexto incompleto o se omite silenciosamente.
**Alcance:**
- Diagnosticar el flujo completo: `WorkspacePage``useProcess` hook → `process-repo.getById()` → render
- Identificar dónde el guard de auth interactúa mal con la carga del proceso
- Corregir sin introducir flickering ni doble render
- El workspace debe cargar al primer intento, sin refresh
**Criterios:**
- [ ] Navegar desde la biblioteca a cualquier proceso carga el workspace en el primer intento
- [ ] Sin errores en consola durante la navegación
- [ ] El estado de loading muestra un skeleton o spinner visible (no pantalla en blanco)
- [ ] Tests Vitest verdes (sin regresiones)
- [ ] Validación visual del director
---
### Etapa 2 — Fix + mejora: zoom BPMN
**Problema:** El botón de fit-viewport actual posiciona el diagrama pegado al borde superior, sin padding. Además no hay controles de zoom explícitos.
**Alcance:**
**Fix fit-viewport:**
- Corregir `canvas.zoom('fit-viewport')` para que el diagrama quede centrado con padding uniforme (sugerido: 40px en todos los bordes)
- bpmn-js expone `canvas.zoom('fit-viewport', true)` y opciones de padding — usar la API correcta
**Controles de zoom (versión wow):**
- Botón "+" (zoom in) y "" (zoom out) en el canvas
- Botón de reset/fit (ícono de encuadre) — corrige el bug actual + sirve de reset
- Posición: esquina inferior derecha del canvas, sin tapar el diagrama
- Estilo: botones cuadrados de 32px, fondo blanco semitransparente, borde sutil, ícono Tabler
- No interferir con el zoom por scroll/pinch que ya funciona
**Íconos sugeridos:** `ti-zoom-in`, `ti-zoom-out`, `ti-focus-2` (para fit)
**Criterios:**
- [ ] Fit-viewport centra el diagrama con padding uniforme (no clip al borde)
- [ ] Botones +//fit visibles en el canvas
- [ ] +/ hacen zoom in/out incremental (factor 1.2x por click)
- [ ] Fit resetea a la vista centrada
- [ ] Los controles no aparecen encima de los paneles laterales
- [ ] Tests Vitest verdes
- [ ] Validación visual del director
---
### Etapa 3 — Heatmap con gradiente continuo + toggle de modo
**Problema actual:** el heatmap usa 3 buckets discretos (verde/amarillo/rojo) con colores fijos. Sin matices intermedios, sin gradiente.
**Alcance:**
**Escala de color continua:**
Reemplazar la lógica de 3 buckets por una función de interpolación de 4 stops:
```
0.0 → #10b981 (verde)
0.33 → #f59e0b (amarillo)
0.67 → #f97316 (naranja)
1.0 → #ef4444 (rojo vivo)
```
Implementar en `src/lib/colors.ts` como `intensityToColor(normalized: number): string` usando interpolación lineal en RGB entre stops consecutivos.
**Dos modos de normalización:**
`percentile` (default): cada actividad se posiciona según su rango relativo entre todas las actividades del proceso. No sufre distorsión por outliers.
`minmax`: `(costo - mínimo) / (máximo - mínimo)`. Refleja valores absolutos pero se comprime con outliers.
**Persistencia del modo:** guardar la preferencia en `localStorage` (key: `heatmap-mode`). Default: `percentile`.
**Toggle en UI:**
- Un toggle compacto en el panel del reporte o en la barra de herramientas del workspace
- Label: "Escala de color: Relativa (percentil) / Absoluta (min-max)"
- Tamaño pequeño, no protagonista — es una preferencia técnica, no el flujo principal
**Dónde aplica el heatmap:**
- Nodos del canvas BPMN (overlay de color en actividades)
- Tabla del reporte web (fondo de celdas de costo)
- PDF: usa los mismos valores calculados pero sin el toggle (renderiza el modo activo al exportar)
**Recalibrar E2E de píxeles:** los tests que verifican colores del canvas BPMN necesitan actualizar sus umbrales/valores esperados para la nueva escala. Identificar los specs afectados y corregirlos.
**Criterios:**
- [ ] El gradiente muestra transiciones suaves (no saltos abruptos de verde a rojo)
- [ ] Modo percentil: actividades distribuidas a lo largo de la escala incluso con outliers
- [ ] Modo min-max: outlier extremo domina la escala (comportamiento esperado)
- [ ] Toggle funciona y persiste entre sesiones
- [ ] PDF exporta con el modo activo al momento de la exportación
- [ ] Tests E2E de heatmap recalibrados y verdes
- [ ] Tests Vitest verdes (nueva función `intensityToColor` con tests unitarios)
- [ ] Validación visual del director
---
### Etapa 4 — Ver procesos por recurso + campo empresa en perfil
Dos features pequeñas en una etapa.
**Feature A — Ver procesos asociados a un recurso:**
En `ResourceCatalogPage` (`/recursos`), cada fila de la tabla debe mostrar cuántos procesos usan ese recurso.
- Nueva columna "Procesos" en la tabla: muestra un badge con el número (ej: `3 procesos`)
- Si es 0: muestra "—" con color muted
- Si es > 0: el badge es clickeable y expande una lista de los procesos (nombre + link directo al workspace)
- La expansión puede ser inline (fila expandible) o un popover/tooltip al hover
- Dato: query a `activity_resource_assignments` agrupado por `resource_id`, con JOIN a `processes` para el nombre
- Esto complementa la protección de borrado existente — ahora el usuario ve *qué* proceso usa el recurso antes de intentar eliminarlo
**Feature B — Campo empresa en perfil de usuario:**
- Nueva columna `company text` en tabla `users` de Supabase (nullable, sin default)
- Migración: `ALTER TABLE users ADD COLUMN IF NOT EXISTS company text;`
- UI: en el dropdown del AppHeader (donde aparece nombre y email), agregar una línea con la empresa si está definida
- Edición: agregar un botón "Editar perfil" en el dropdown que abre un Sheet con campos editables: nombre y empresa (email es de solo lectura — viene de Google OAuth)
- La empresa se muestra también en la tabla de la biblioteca si hay espacio (opcional, decidir con validación visual)
**Criterios:**
- [ ] Columna "Procesos" visible en `/recursos` con conteo correcto
- [ ] Click en el badge con conteo > 0 muestra la lista de procesos con links funcionales
- [ ] Columna `company` creada en Supabase sin romper datos existentes
- [ ] El dropdown del AppHeader muestra la empresa si está definida
- [ ] Sheet de edición de perfil guarda nombre y empresa en Supabase
- [ ] Tests Vitest verdes
- [ ] Validación visual del director
---
### Etapa 5 — Columna "Eficiencia de automatización"
**Contexto:** la tabla de comparación (ComparisonTable) hoy muestra costo AS-IS, costo TO-BE, ahorro y probabilidad. Le falta una métrica que permita *rankear* actividades por valor de automatización — no solo quién ahorra más en valor absoluto, sino qué actividades ofrecen el mejor retorno por peso invertido.
**Métrica:**
```
eficiencia = (ahorro$ × probabilidad) / tiempo_ejecucion_minutos
```
Alternativa si no hay inversión por actividad: usar `ahorro$ × probabilidad` como proxy simple (ahorro esperado ponderado). Decidir en implementación cuál es más interpretable para un consultor.
**Alcance:**
- Nueva columna "Eficiencia" en `ComparisonTable` del reporte web
- Valor = ahorro esperado ponderado por probabilidad de automatización
- Ordenar la tabla por esta columna por defecto (mayor eficiencia primero) — con posibilidad de cambiar el orden haciendo click en el header
- Versión wow: barra de progreso inline de ancho relativo (la actividad más eficiente = 100%, las demás proporcionales). Mismo estilo que las barras del heatmap
- Tooltip en el header de la columna explicando la fórmula
- Si `isAutomatable = false`: mostrar "—" (em dash), igual que columna Ahorro
**En PDF:**
- Incluir la columna en la tabla de comparación del PDF si entra sin truncamiento
- Si no entra, omitirla (el PDF ya es denso) — decidir en validación visual
**Criterios:**
- [ ] Columna visible en el reporte web con valores correctos
- [ ] Actividades no automatizables muestran "—"
- [ ] Orden por defecto: mayor eficiencia primero
- [ ] Click en header reordena la tabla
- [ ] Barra de progreso proporcional visible
- [ ] Tooltip en header con explicación de la fórmula
- [ ] Cálculo tiene tests unitarios en Vitest
- [ ] Validación visual del director
---
### Etapa 6 — Limpieza técnica
**6a — E2E: reescribir specs Dexie → Supabase**
- `export-pdf.spec.ts` y `validate-dod-etapa4.spec.ts` inyectan datos vía Dexie/IndexedDB, que fue eliminado en Sprint 4
- Reescribir fixtures para usar el test user `martin.bernal@inquality.com.py` via Supabase
- Limpiar datos de test después de cada corrida (usar `afterAll` con `service_role_key`)
**6b — E2E: fix flakiness "Guardar cambios"**
- 3 specs fallan intermitentemente por timing: Playwright no encuentra el botón en estado habilitado
- Fix: `await page.waitForSelector('[data-testid="save-btn"]:not([disabled])')` o equivalente
- Agregar `data-testid="save-btn"` al botón en `ActivityPanel.tsx` si no existe
**6c — Limpieza de BACKLOG y documentación**
- Marcar como `[RESOLVED]` en `BACKLOG.md`: "Indicador de simulación desactualizada", "Edición inline de proceso/cliente", "Controles de zoom BPMN" (resueltos en Sprints 2/3 y Etapa 2 de este sprint)
- Actualizar `STRATEGIC_DIRECTION.md` con cierre de Sprint 4 (pendiente desde sesión anterior)
- Vaciar `OBSERVATIONS.md` y preparar encabezado para Sprint 5
- Marcar como `[RESOLVED]` en `TECH_DEBT.md` los ítems resueltos en esta etapa
**Criterios:**
- [ ] `export-pdf.spec.ts` y `validate-dod-etapa4.spec.ts` verdes en CI con Supabase real
- [ ] 3 specs flaky del botón "Guardar" estables
- [ ] `npm run test` — mantener o superar conteo anterior de Vitest
- [ ] `npx playwright test` — 0 specs permanentemente rojos
- [ ] BACKLOG, TECH_DEBT, STRATEGIC_DIRECTION y OBSERVATIONS actualizados
- [ ] Validación del director sobre el estado de los archivos
---
## Distribución del sprint
| Área | Etapas | % |
|---|---|---|
| Features de producto / UX | 1, 2, 3, 4, 5 | ~83% |
| Infraestructura / deuda técnica | 6 | ~17% |
| PDF | (toca en Etapa 3 y 5, sin etapa dedicada) | — |
Dentro del rango válido del rebalanceo (60-70% web, 10-15% infra). PDF sin etapa dedicada — aceptable este sprint dado que Sprint 4 no tocó PDF.
---
## Qué NO entra en Sprint 5
- Historial de simulaciones / múltiples simulaciones por proceso
- Implementación real de `setShared()` (diferida hasta primer guest)
- Keycloak / migración de auth
- Modo offline / PWA
- i18n
- Export Excel
- Gateway OR inclusivo
---
## Definition of Done del sprint
- [ ] 6 etapas con OK formal del director (validación visual de cada una)
- [ ] `npm run build` limpio
- [ ] `npm run lint` sin warnings
- [ ] Tests Vitest: mantener o superar conteo de Sprint 4
- [ ] Tests E2E: 0 specs permanentemente rojos
- [ ] CHECKLIST_ENTREGA.md ejecutado antes de cada entrega de etapa
- [ ] OBSERVATIONS.md con hallazgos del sprint al cierre
- [ ] STRATEGIC_DIRECTION.md actualizado con Sprint 4 y 5

View File

@@ -0,0 +1,192 @@
# Sprint 5 — Etapa 1: Fix crítico workspace loading
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase.
Sprint 4 completado. La app tiene auth (Google OAuth + Supabase), PostgreSQL, RLS, catálogo de recursos global.
**Bug en producción:** al navegar desde la biblioteca (`/`) a un proceso (`/workspace/:processId`), la pantalla del workspace queda cargando indefinidamente. Solo carga correctamente después de un refresh manual de la página.
---
## Causa raíz — ya diagnosticada, no requiere investigación adicional
El diagnóstico es preciso. No es necesario "investigar el flujo" — el problema está localizado.
**`src/store/process-store.ts`** llama a `supabase.auth.getSession()` directamente como barrera de sesión antes del fetch. Esta llamada no tiene timeout: si Supabase tarda en responder (token refresh en curso, red lenta, lock interno), `getSession()` se bloquea indefinidamente → `loadProcess` nunca resuelve → `isLoading` queda `true` para siempre.
**`src/store/library-store.ts`** hace lo mismo pero usa `getSessionSafe()` — una función en `src/lib/supabase.ts` que hace race entre `getSession()` y un timeout de 4 segundos, garantizando que siempre resuelve. Por eso la biblioteca carga y el workspace no.
**La inconsistencia es sistémica:** el mismo patrón puede existir en otros stores. Esta etapa lo audita y corrige en todos.
---
## Parte 1 — Fix en process-store.ts
En `src/store/process-store.ts`, localizar la llamada a `supabase.auth.getSession()` (usada como barrera antes del query principal) y reemplazarla por `getSessionSafe()`:
```typescript
// Antes:
await supabase.auth.getSession()
// Después:
await getSessionSafe()
```
Agregar el import si no está:
```typescript
import { supabase, getSessionSafe } from '@/lib/supabase'
```
Verificar que `getSessionSafe` ya existe en `src/lib/supabase.ts`. Si por alguna razón no existe, crearla siguiendo el mismo patrón que se usa en `library-store.ts`.
---
## Parte 2 — Auditar todos los stores
Buscar en `src/store/` y `src/persistence/` cualquier archivo que llame a `supabase.auth.getSession()` directamente (sin pasar por `getSessionSafe`). Reportar qué archivos tienen este patrón.
Para **cada archivo** que lo use:
- Si es una barrera de sesión antes de un query → reemplazar por `getSessionSafe()`
- Si es otro uso (ej: verificar si hay sesión activa para tomar una decisión, no como barrera) → evaluar caso por caso y documentar la decisión
---
## Parte 3 — Estado de error en el workspace
**Problema adicional:** actualmente, si `getSessionSafe()` agota su timeout, `loadProcess` lanza un error, pero el workspace no tiene un estado `error` propio. El resultado es que `isLoading` puede quedar `true` o la pantalla queda vacía sin mensaje al usuario.
**Fix requerido:** en `process-store.ts` (y el hook/componente que lo consume), agregar manejo de estado de error:
```typescript
// En el store, el estado debe incluir:
{
isLoading: boolean
error: string | null // 🆕
process: Process | null
}
// En loadProcess, al catch:
set({ isLoading: false, error: 'No se pudo cargar el proceso. Revisá tu conexión.' })
```
**En WorkspacePage.tsx** (o el componente raíz del workspace), manejar el estado `error`:
```tsx
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-sm text-muted-foreground">{error}</p>
<Button variant="outline" onClick={() => loadProcess(processId)}>
Reintentar
</Button>
</div>
)
}
```
No usar `alert()` ni logs crudos. El error debe ser visible, accionable, y en español.
---
## Parte 4 — Skeleton screen en el workspace
**Estado actual:** el workspace muestra un spinner genérico mientras carga. En una app moderna esto es insuficiente — el usuario no sabe qué está cargando ni cuánto ocupa la pantalla.
**Requerimiento:** reemplazar el spinner de carga del workspace por un skeleton screen que preserve el layout de la página.
El workspace tiene una estructura aproximada de: barra superior + panel BPMN (zona grande central/izquierda) + panel lateral (ActivityPanel/ResourcesPanel).
Crear un componente `WorkspaceSkeleton` que muestre:
- Header con altura igual al real (sin contenido)
- Zona del canvas BPMN: rectángulo con `animate-pulse` en gris claro, misma altura aproximada
- Panel lateral: 3-4 filas de skeleton (títulos y texto en gris)
Usar `shadcn/ui Skeleton` si ya está disponible en el proyecto, o implementar con Tailwind `animate-pulse`:
```tsx
<div className="animate-pulse bg-muted rounded-md h-full w-full" />
```
El skeleton se muestra cuando `isLoading === true` y no hay `process` todavía. Una vez que `process` está disponible, el skeleton desaparece y el workspace real se monta.
---
## Parte 5 — React Error Boundary en el workspace
Una app de nueva generación no deja que un error de renderizado en el workspace blankee la pantalla completa. Agregar un Error Boundary al nivel del workspace.
Si ya existe un `ErrorBoundary` en el proyecto (`src/components/ErrorBoundary.tsx` o similar), usarlo. Si no existe, crear uno mínimo:
```tsx
// src/components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react'
interface Props { children: ReactNode; fallback?: ReactNode }
interface State { hasError: boolean; error: Error | null }
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div className="flex flex-col items-center justify-center h-full gap-2 p-8">
<p className="text-sm font-medium text-destructive">Algo salió mal al cargar el workspace.</p>
<p className="text-xs text-muted-foreground">{this.state.error?.message}</p>
<button
className="text-xs underline text-muted-foreground"
onClick={() => window.location.reload()}
>
Recargar la página
</button>
</div>
)
}
return this.props.children
}
}
```
Envolver `WorkspacePage` (o el contenido del workspace) con `<ErrorBoundary>` en el router o en `App.tsx`.
---
## Qué NO entra en esta etapa
- Cambios en el diseño visual del workspace (paneles, BPMN canvas, ActivityPanel)
- Cambios en la lógica de simulación
- Cambios en recursos, reporte, o PDF
- Nuevas features de producto
- Cambios en auth (login, logout, OAuth)
---
## Orden de ejecución
1. Abrir `src/store/process-store.ts` — reemplazar `getSession()` por `getSessionSafe()`
2. Grep en `src/store/` y `src/persistence/` para `getSession()` — auditar y corregir
3. Agregar campo `error` al store y manejo en `WorkspacePage`
4. Crear `WorkspaceSkeleton` y usarlo mientras `isLoading === true`
5. Crear `ErrorBoundary` (si no existe) y envolver el workspace
6. `npm run build` — 0 errores TypeScript
7. `npm run lint` — 0 warnings en archivos tocados
8. `npm run test` — mantener conteo anterior de tests Vitest (sin regresiones)
9. Commit: `fix(sprint-5/etapa-1): workspace loading — getSessionSafe + error state + skeleton`
---
## Criterios de validación
- [ ] Navegar desde la biblioteca a cualquier proceso carga el workspace en el primer intento (sin refresh)
- [ ] Si el proceso no carga en ~5s, se muestra mensaje de error con botón "Reintentar"
- [ ] El estado de carga muestra un skeleton screen que preserva el layout (no spinner en blanco)
- [ ] Si hay un error de renderizado en el workspace, la app muestra el Error Boundary (no pantalla en blanco)
- [ ] Todos los stores auditados — ninguno usa `getSession()` como barrera de sesión sin timeout
- [ ] `npm run build` limpio
- [ ] `npm run test` — sin regresiones
- [ ] **Validación visual del director** antes del OK formal

View File

@@ -0,0 +1,156 @@
# Sprint 5 — Etapa 2: Fix zoom BPMN + skeleton carry-over
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase.
Etapa 1 completada con OK formal. Workspace carga correctamente. Dos ítems pendientes de esta etapa:
1. **Carry-over de Etapa 1:** `WorkspaceSkeleton.tsx` fue creado y está bien implementado, pero no aparece en la práctica — el spinner del router intercepta antes. Corregir la condición de render en `WorkspacePage.tsx`.
2. **Fix + mejora del canvas BPMN:** botón fit-viewport con bug (clip al borde superior) y ausencia de controles zoom-in/zoom-out.
---
## Fix 0 — Skeleton carry-over (Etapa 1)
### Diagnóstico
`WorkspaceSkeleton.tsx` existe en `src/features/workspace/` y está bien implementado (layout completo con `animate-pulse`). El problema está en `WorkspacePage.tsx`: el skeleton no se renderiza cuando `isLoading === true` en el store, porque TanStack Router muestra su propio indicador de carga antes de que `WorkspacePage` monte y evalúe su estado interno.
### Fix
Abrir `WorkspacePage.tsx` (o `WorkspacePageWithBoundary` si así quedó tras Etapa 1) y localizar la condición de loading. Debe quedar así:
```tsx
// Al inicio del componente, después de leer el store:
const { isLoading, error, process } = useProcessStore()
if (isLoading) return <WorkspaceSkeleton />
if (error) return <WorkspaceError error={error} onRetry={...} onBack={...} />
if (!process) return <WorkspaceSkeleton /> // guard adicional para estado transitorio
```
El objetivo es que el skeleton aparezca mientras `isLoading === true` **desde el componente**, no depender del loader de TanStack Router para mostrar feedback visual al usuario.
Si el router-level loader también muestra algo, no hay conflicto — el router puede tener su indicador mínimo (barra de progreso en el top si la hay) y el componente muestra el skeleton completo una vez montado. No eliminar el router loader si existe, solo asegurarse de que el componente también maneje su propio estado de carga con el skeleton.
**Verificación:** con DevTools → Network → Slow 3G, navegar desde biblioteca a proceso. Debe aparecer el skeleton (bloques grises animados preservando el layout) durante la carga, no una rueda giratoria sobre fondo en blanco.
---
## Fix 1 — Fit-viewport: botón sin argumento 'auto'
### Diagnóstico
En `BpmnCanvas.tsx`, el botón de encuadre llama:
```ts
canvas.zoom('fit-viewport') // ← sin segundo argumento
```
El load inicial correcto usa:
```ts
canvas.zoom('fit-viewport', 'auto') // ← con 'auto', centra el diagrama
```
Sin `'auto'`, bpmn-js hace fit pero sin centrar — el diagrama queda pegado al borde superior izquierdo del viewport. Con `'auto'`, centra el diagrama en el viewport con el zoom justo para que entre.
### Fix
En el handler del botón de encuadre (botón con ícono `Maximize2` en `BpmnCanvas.tsx`), agregar el argumento `'auto'`:
```ts
// Antes:
canvas.zoom('fit-viewport')
// Después:
canvas.zoom('fit-viewport', 'auto')
```
Una línea. No tocar el resto del componente.
---
## Fix 2 — Controles de zoom-in y zoom-out
### Contexto del layout
`BpmnCanvas.tsx` tiene:
- Wrapper externo: `position: relative, width: 100%, height: 100%`
- `containerRef` (donde monta bpmn-js): `width: 100%, height: 100%`
- Botón fit-viewport existente: `position: absolute, top: 12, right: 12`
Los botones nuevos se posicionan sobre el canvas usando `position: absolute`.
### Implementación
Agregar dos botones: zoom-in (+) y zoom-out (). Junto al botón fit existente, formando un grupo de 3 controles.
**Posición:** esquina inferior derecha del canvas (no esquina superior — para no interferir con el botón fit existente que ya está arriba). Usar `bottom: 12, right: 12`.
**Lógica:**
```ts
const handleZoomIn = () => {
if (!viewerRef.current) return
const canvas = viewerRef.current.get('canvas') as any
const currentZoom = canvas.zoom()
canvas.zoom(Math.min(currentZoom * 1.2, 4)) // máximo zoom: 4x
}
const handleZoomOut = () => {
if (!viewerRef.current) return
const canvas = viewerRef.current.get('canvas') as any
const currentZoom = canvas.zoom()
canvas.zoom(Math.max(currentZoom / 1.2, 0.1)) // mínimo zoom: 0.1x
}
```
Los límites `min: 0.1` y `max: 4` previenen que el usuario llegue a un estado de zoom irrecuperable.
**Estilo de los botones** — consistente con el botón fit existente:
- Tamaño: 28×28px o 32×32px (el que use el botón fit actual)
- Fondo: blanco semitransparente con borde sutil
- Ícono Tabler: `ti-zoom-in` para +, `ti-zoom-out` para
- Separación entre botones: 4px (grupo compacto)
- Disposición: verticalmente apilados (+ arriba, abajo) o en fila horizontal — lo que quede más compacto sin solapar el diagrama
**No reemplazar el zoom por scroll/pinch** — esos eventos de bpmn-js siguen funcionando igual. Estos botones son controles adicionales para usuarios que no tienen scroll o prefieren click explícito.
**Extraer en subcomponente si mejora la legibilidad** — si el bloque de JSX del canvas empieza a crecer, considerar un `<BpmnZoomControls onZoomIn={...} onZoomOut={...} onFit={...} />` separado. Solo si la extracción es natural; no forzar si el componente ya es compacto.
---
## Qué NO entra en esta etapa
- Cambios en la lógica de simulación o cálculos
- Cambios en paneles laterales (ActivityPanel, ResourcesPanel)
- Cambios en el reporte o PDF
- Nuevas features de producto
- Cambios en auth o Supabase
---
## Orden de ejecución
1. Fix 0 — `WorkspacePage.tsx`: renderizar `WorkspaceSkeleton` cuando `isLoading === true`
2. Fix 1 — `BpmnCanvas.tsx`: agregar `'auto'` al botón fit-viewport
3. Fix 2 — `BpmnCanvas.tsx`: agregar botones zoom-in y zoom-out en esquina inferior derecha
4. `npm run build` — 0 errores TypeScript
5. `npm run lint` — 0 warnings nuevos
6. `npm run test` — mantener 521+ tests Vitest verdes
7. `npx playwright test tests/e2e/sprint-4-smoke.spec.ts` — 9/9 verdes
8. Commit: `fix(sprint-5/etapa-2): skeleton workspace + fit-viewport center + zoom controls`
---
## Criterios de validación
- [ ] Con Slow 3G: navegar desde biblioteca a proceso muestra skeleton (no spinner genérico) durante la carga
- [ ] Botón fit-viewport centra el diagrama con padding visible en todos los bordes (no clip al borde superior)
- [ ] Botones zoom-in y zoom-out visibles en el canvas
- [ ] Zoom-in hace la imagen más grande (factor ~1.2x por click)
- [ ] Zoom-out hace la imagen más pequeña (factor ~1.2x por click)
- [ ] Zoom por scroll/pinch sigue funcionando sin interferencia
- [ ] Los controles no tapan el diagrama ni los paneles laterales
- [ ] Build limpio, lint sin warnings nuevos, 521+ tests Vitest verdes
- [ ] **Validación visual del director** antes del OK formal

View File

@@ -0,0 +1,138 @@
# Sprint 5 — Etapa 3B: Tres fixes post-validación visual
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase.
Etapa 3 completada técnicamente. Durante la validación visual del director se detectaron 3 bugs que bloquean el OK formal. Esta etapa los corrige antes de avanzar a Etapa 4.
---
## Fix 1 — Toggle no re-pinta el heatmap del canvas
### Síntoma
Al cambiar entre "Costo relativo (%)" y "Costo absoluto" en el reporte, el toggle cambia visualmente pero los colores del canvas BPMN y de la tabla de actividades no se actualizan.
### Causa probable
`applyHeatmapToViewer()` en `HeatmapCanvas.tsx` se llama una sola vez después de la simulación (en un `useEffect` con dependencia en los datos de simulación). Cuando el `mode` cambia, ese efecto no se re-ejecuta — el canvas ya está pintado y no se re-pinta.
### Fix
**Parte A — Canvas BPMN:**
En el componente que contiene `HeatmapCanvas` (probablemente `ReportPage.tsx` o el propio `HeatmapCanvas.tsx`), agregar `mode` como dependencia del `useEffect` que llama a `applyHeatmapToViewer()`:
```tsx
useEffect(() => {
if (!simulationData || !viewerReady) return
applyHeatmapToViewer(viewer, simulationData.activities, mode)
}, [simulationData, viewerReady, mode]) // ← agregar mode
```
Si `applyHeatmapToViewer` recibe el mode como argumento, verificar que se lo está pasando correctamente. Si no lo recibe aún, agregarlo como parámetro.
**Parte B — Tabla de actividades:**
Verificar que `ActivitiesTable.tsx` recibe el `mode` actualizado como prop. Si el `mode` viene del mismo estado del toggle, la tabla debería re-renderizarse automáticamente. Si hay una desconexión (el toggle actualiza un estado local en un componente y la tabla lee de otro), unificarlos para que ambos lean del mismo `useHeatmapMode()` hook.
**El resultado esperado:** cambiar el toggle debe re-pintar el canvas BPMN y actualizar los colores de la tabla en tiempo real, sin necesidad de re-simular.
---
## Fix 2 — Persistencia del modo no funciona (Cmd+R resetea a default)
### Síntoma
Al seleccionar "Costo absoluto" y recargar la página (Cmd+R), el toggle vuelve a "Costo relativo (%)". La preferencia no persiste en localStorage.
### Causa probable
`ReportPage.tsx` probablemente todavía usa su propio `useState('relative')` o `useState('percentile')` en lugar de consumir `useHeatmapMode()`. El hook fue creado pero no conectado al componente.
### Fix
**Paso 1:** Buscar en `ReportPage.tsx` (y cualquier componente que renderice el toggle) si usa `useState` para el modo del heatmap. Si lo hace, reemplazarlo por `useHeatmapMode()`:
```tsx
// Antes (estado local, no persiste):
const [mode, setMode] = useState<HeatmapMode>('percentile')
// Después (hook con localStorage):
const [mode, setMode] = useHeatmapMode()
```
**Paso 2:** Verificar que `useHeatmapMode()` lee correctamente de localStorage al inicializar. La implementación debe leer el valor almacenado en el `useState` initializer (función lazy), no en un `useEffect`:
```tsx
// ✅ Correcto — lee localStorage ANTES del primer render:
const [mode, setModeState] = useState<HeatmapMode>(() => {
try {
const stored = localStorage.getItem('inq-roi:heatmap-mode')
return (stored === 'percentile' || stored === 'minmax') ? stored : 'percentile'
} catch {
return 'percentile'
}
})
// ❌ Incorrecto — el useEffect corre DESPUÉS del primer render, hay flash:
const [mode, setModeState] = useState<HeatmapMode>('percentile')
useEffect(() => {
const stored = localStorage.getItem('inq-roi:heatmap-mode')
if (stored) setModeState(stored as HeatmapMode)
}, [])
```
**Verificación:** tras el fix, cambiar a "Costo absoluto" → Cmd+R → el toggle debe aparecer en "Costo absoluto" desde el primer render, sin flash.
---
## Fix 3 — Controles de zoom siguen solapando logo bpmn.io
### Síntoma
Los botones de zoom-in/zoom-out en el canvas BPMN siguen en la esquina inferior derecha, solapando el watermark `BPMN.iO`.
### Causa probable
El Fix 0 de Etapa 3 no se aplicó correctamente, o se cambió la posición del botón de encuadre (fit) en lugar de los controles de zoom-in/zoom-out.
### Fix
Abrir `BpmnCanvas.tsx`. Localizar el grupo de botones de zoom-in y zoom-out. Debe estar posicionado con `position: 'absolute'`.
**Si está en `right: 12`:** cambiar a `left: 12`. El botón de encuadre (fit-viewport, ícono `Maximize2` o `Focus`) en la esquina superior derecha **no se mueve** — ese no solapa nada.
```tsx
// Grupo de controles zoom-in / zoom-out:
// Antes:
style={{ position: 'absolute', bottom: 12, right: 12 }}
// Después:
style={{ position: 'absolute', bottom: 12, left: 12 }}
```
**Verificación:** el logo `BPMN.iO` debe ser completamente visible en la esquina inferior derecha. Los controles de zoom deben estar en la esquina inferior izquierda sin tapar ningún elemento del canvas.
---
## Qué NO entra en esta etapa
- Cambios en la lógica de simulación
- Cambios en el esquema de Supabase
- Nuevas features fuera del heatmap y controles de zoom
---
## Orden de ejecución
1. Fix 3 — posición controles zoom en `BpmnCanvas.tsx` (1-2 líneas)
2. Fix 2 — conectar `useHeatmapMode()` en `ReportPage.tsx` (reemplazar useState local)
3. Fix 1 — agregar `mode` como dependencia en el useEffect que llama `applyHeatmapToViewer()`
4. `npm run build` — 0 errores TypeScript
5. `npm run lint` — 0 warnings nuevos
6. `npm run test` — 528+ tests Vitest verdes
7. Commit: `fix(sprint-5/etapa-3b): heatmap toggle repaint + localStorage + zoom position`
---
## Criterios de validación
- [ ] Cambiar el toggle re-pinta el canvas BPMN en tiempo real (sin re-simular)
- [ ] Cambiar el toggle actualiza los colores de la tabla de actividades en tiempo real
- [ ] Seleccionar "Costo absoluto" → Cmd+R → el toggle sigue en "Costo absoluto"
- [ ] Controles zoom-in/zoom-out en esquina inferior izquierda sin solapar bpmn.io
- [ ] 528+ tests Vitest verdes
- [ ] **Validación visual del director** antes del OK formal

View File

@@ -0,0 +1,250 @@
# Sprint 5 — Etapa 3: Heatmap gradiente continuo + fix posición zoom
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase.
Etapas 1 y 2 completadas con OK formal. Esta etapa tiene un fix carry-over de Etapa 2 y la mejora principal del heatmap.
---
## Fix 0 — Posición controles de zoom (carry-over Etapa 2)
En `BpmnCanvas.tsx`, los controles de zoom-in/zoom-out quedaron en la esquina inferior derecha (`bottom: 12, right: 12`), solapando el logo `bpmn.io` que bpmn-js renderiza en esa esquina por licencia.
**Fix:** cambiar la posición del grupo de controles de zoom a la esquina inferior izquierda.
```tsx
// Antes:
style={{ position: 'absolute', bottom: 12, right: 12, ... }}
// Después:
style={{ position: 'absolute', bottom: 12, left: 12, ... }}
```
El botón de encuadre (fit-viewport, esquina superior derecha) no se mueve — solo el grupo zoom-in/zoom-out.
---
## Mejora principal — Heatmap gradiente con percentil
### Contexto del sistema actual
El sistema de heatmap ya existe y está centralizado en `src/lib/colors.ts`. Tiene:
- Dos modos: `'relative'` (normalización por máximo de porcentaje) y `'absolute'` (min-max sobre costo)
- Gradiente de 3 stops: verde `#10b981` → ámbar `#f59e0b` → rojo `#ef4444`
- Función central `activityColor()` usada por todos los consumidores
- Compuerta de varianza: si varianza < 5%, todos reciben color neutro `#cbd5e1`
- El toggle de modo ya existe en `ReportPage.tsx` como estado local (no persistente)
El PDF captura el canvas coloreado con `html2canvas`, pero tiene una **leyenda hardcodeada** con los mismos 3 stops en `src/lib/export/pdf-sections.ts` (función `drawHeatmapLegend()`). Hay que mantenerla sincronizada.
### Cambio 1 — Cuarto stop en el gradiente (naranja)
Modificar `colors.ts` para usar 4 stops en lugar de 3:
```
0.00 → #10b981 (verde)
0.33 → #f59e0b (amarillo)
0.67 → #f97316 (naranja) ← nuevo
1.00 → #ef4444 (rojo vivo)
```
La función de interpolación actual usa `lerp` sobre 3 stops. Extenderla para que soporte N stops genéricos:
```ts
function interpolateColor(t: number, stops: [number, string][]): string {
// t está clampeado a [0, 1] antes de entrar
const clamped = Math.max(0, Math.min(1, t))
for (let i = 0; i < stops.length - 1; i++) {
const [t0, c0] = stops[i]
const [t1, c1] = stops[i + 1]
if (clamped <= t1) {
const localT = (clamped - t0) / (t1 - t0)
return lerpColor(c0, c1, localT) // interpolación RGB lineal
}
}
return stops[stops.length - 1][1]
}
const HEATMAP_STOPS: [number, string][] = [
[0.00, '#10b981'],
[0.33, '#f59e0b'],
[0.67, '#f97316'],
[1.00, '#ef4444'],
]
```
Mantener `NEUTRAL_HEATMAP_COLOR = '#cbd5e1'` para el caso de varianza nula.
### Cambio 2 — Modo `'percentile'` reemplaza `'relative'`
**Renombrar** `'relative'``'percentile'` en el tipo `HeatmapMode`. Actualizar todos los usos (buscar con grep).
**Implementar normalización por percentil real** en `computeActivityT()`:
```ts
function percentileNorm(
current: ActivityWithCost,
all: ActivityWithCost[]
): number {
const n = all.length
if (n <= 1) return 0.5
const currentCost = current.expectedTotalCost
// Rango: cantidad de actividades con costo estrictamente menor
const rank = all.filter(a => a.expectedTotalCost < currentCost).length
// Normalizar a [0, 1]: 0 = la más barata, 1 = la más cara
// Ties reciben el mismo rango (densidad uniforme)
return rank / (n - 1)
}
```
**Edge cases obligatorios:**
- `n === 1`: retornar `0.5` (actividad única → color intermedio)
- Todas iguales (`max === min` o todos los ranks son 0): la compuerta de varianza ya maneja esto con `#cbd5e1` — verificar que siga funcionando con percentil
- `expectedTotalCost < 0`: clampear `t` a 0 (costo negativo = "más barato posible")
**Modo `'minmax'`** (renombrado de `'absolute'`): mantener la lógica existente, solo renombrar el string.
### Cambio 3 — Persistencia del modo en localStorage
El toggle hoy es estado local en `ReportPage.tsx` — se pierde al navegar o recargar.
Crear un hook `useHeatmapMode()` en `src/lib/colors.ts` o en `src/lib/hooks/useHeatmapMode.ts`:
```ts
const STORAGE_KEY = 'inq-roi:heatmap-mode'
const DEFAULT_MODE: HeatmapMode = 'percentile'
export function useHeatmapMode(): [HeatmapMode, (mode: HeatmapMode) => void] {
const [mode, setModeState] = useState<HeatmapMode>(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
// Validar que el valor guardado sea un modo válido
return (stored === 'percentile' || stored === 'minmax') ? stored : DEFAULT_MODE
} catch {
return DEFAULT_MODE
}
})
const setMode = (newMode: HeatmapMode) => {
try {
localStorage.setItem(STORAGE_KEY, newMode)
} catch {
// localStorage no disponible — continuar sin persistir
}
setModeState(newMode)
}
return [mode, setMode]
}
```
Reemplazar el `useState` local en `ReportPage.tsx` por `useHeatmapMode()`. Si el heatmap también se controla desde el workspace (canvas BPMN), usar el mismo hook allí.
**Nota:** el valor guardado en localStorage debe validarse antes de usarse (el hook ya lo hace con el check de `stored === 'percentile' || stored === 'minmax'`). Nunca confiar en strings crudos de localStorage sin validación.
### Cambio 4 — Actualizar leyenda del PDF
En `src/lib/export/pdf-sections.ts`, la función `drawHeatmapLegend()` tiene los colores hardcodeados en RGB con 3 stops. Actualizar a 4 stops para mantener sincronía visual con la web:
```ts
// Reemplazar los stops hardcodeados por los mismos 4:
const heatStops = [
{ t: 0.00, r: 16, g: 185, b: 129 }, // #10b981 verde
{ t: 0.33, r: 245, g: 158, b: 11 }, // #f59e0b amarillo
{ t: 0.67, r: 249, g: 115, b: 22 }, // #f97316 naranja
{ t: 1.00, r: 239, g: 68, b: 68 }, // #ef4444 rojo
]
```
La lógica de 40 segmentos puede mantenerse — solo actualizan los puntos de referencia.
El PDF siempre usa el modo activo al momento de exportar (lo que está en localStorage). No hace falta cambiar la lógica de exportación — el canvas ya está coloreado antes de que `html2canvas` lo capture.
### Cambio 5 — Toggle en UI
El toggle ya existe en `ReportPage.tsx`. Solo actualizar:
- Strings del label: `'relative'``'percentile'`, `'absolute'``'minmax'`
- Texto visible: "Relativo" → "Percentil", "Absoluto" → "Min-max"
- Conectar al hook `useHeatmapMode()` en lugar del estado local
Si el toggle también existe o debe existir en el workspace (para ver el heatmap en el canvas), agregar el mismo toggle allí usando el mismo hook.
---
## Tests obligatorios
### Tests unitarios en Vitest — `colors.ts`
Agregar o actualizar tests para:
```ts
// Normalización percentil
test('percentile: actividad más barata → t = 0', ...)
test('percentile: actividad más cara → t = 1', ...)
test('percentile: actividades con mismo costo → mismo t', ...)
test('percentile: n=1 → t = 0.5', ...)
// Interpolación de color
test('intensityToColor(0) → #10b981', ...)
test('intensityToColor(1) → #ef4444', ...)
test('intensityToColor(0.5) → color naranja intermedio (entre #f59e0b y #f97316)', ...)
test('intensityToColor(-0.1) → mismo que intensityToColor(0) [clamp]', ...)
test('intensityToColor(1.5) → mismo que intensityToColor(1) [clamp]', ...)
// Compuerta de varianza
test('todas las actividades con mismo costo → color neutro #cbd5e1', ...)
```
### Tests E2E — recalibrar specs de píxeles
Buscar en `tests/e2e/` los specs que verifican colores del canvas BPMN (probablemente `etapa-*.spec.ts` o `heatmap*.spec.ts`). Actualizarlos para los nuevos valores de color del gradiente de 4 stops.
Si un spec verifica que "la actividad más costosa es roja" — sigue siendo válido. Si verifica un color RGB exacto con los 3 stops anteriores — actualizar al nuevo gradiente.
---
## Qué NO entra en esta etapa
- Cambios en el motor de simulación o cálculos de ROI
- Cambios en el esquema de Supabase
- Nuevas columnas en tablas o paneles
- Exportación a Excel
- Cualquier feature fuera del heatmap y el fix de posición zoom
---
## Orden de ejecución
1. Fix 0 — mover controles zoom a `left: 12` en `BpmnCanvas.tsx`
2. Agregar cuarto stop naranja al gradiente en `colors.ts`
3. Implementar `percentileNorm()` y renombrar modos en `colors.ts`
4. Crear `useHeatmapMode()` hook con persistencia localStorage
5. Actualizar `ReportPage.tsx` para usar `useHeatmapMode()` (y workspace si aplica)
6. Actualizar `drawHeatmapLegend()` en `pdf-sections.ts` con 4 stops
7. Escribir/actualizar tests unitarios en Vitest para las funciones de color
8. Buscar y recalibrar specs E2E que verifiquen colores del heatmap
9. `npm run build` — 0 errores TypeScript
10. `npm run lint` — 0 warnings nuevos
11. `npm run test` — 521+ Vitest verdes
12. `npx playwright test` — sin nuevos rojos
13. Commit: `feat(sprint-5/etapa-3): heatmap gradiente 4 stops + percentil + localStorage + fix zoom position`
---
## Criterios de validación
- [ ] Controles de zoom en esquina inferior izquierda — sin solapamiento con logo bpmn.io
- [ ] El heatmap del canvas BPMN muestra transiciones suaves (verde → amarillo → naranja → rojo)
- [ ] Con outlier extremo en modo percentil: las actividades restantes tienen colores variados (no todas verde)
- [ ] Con outlier extremo en modo min-max: las actividades restantes se comprimen hacia el verde
- [ ] Toggle "Percentil / Min-max" visible en el reporte — cambia el heatmap en tiempo real
- [ ] El modo persiste entre recargas (verificar con localStorage en DevTools)
- [ ] PDF: la leyenda del heatmap muestra el gradiente de 4 stops
- [ ] Tests unitarios de `colors.ts` todos verdes
- [ ] E2E specs de heatmap recalibrados y verdes
- [ ] **Validación visual del director** antes del OK formal

View File

@@ -0,0 +1,105 @@
# Sprint 5 — Etapa 4B: Fix loop infinito al guardar perfil
## Contexto
Etapa 4 completada con validación visual parcial. Al guardar cambios en el Sheet de "Editar perfil" (nombre o empresa), la app se congela indefinidamente. El resto de Etapa 4 está OK.
## Diagnóstico
El síntoma (app congela, no responde) es loop infinito en React. La secuencia probable:
1. Usuario guarda perfil → `UPDATE users SET name=?, company=? WHERE id=?`
2. `updateUser({ name, company })` actualiza el `AuthUser` en el store/context local
3. Esa actualización de estado dispara un `useEffect` o el `onAuthStateChange` callback
4. Ese callback re-lee la tabla `users` desde Supabase (`SELECT company, name FROM users WHERE id=?`)
5. Al recibir el resultado, vuelve a llamar `updateUser(...)` con los mismos valores
6. → Loop infinito
La raíz del problema es que el `onAuthStateChange` que se implementó en Etapa 4 para leer `company` y `name` de la DB se re-dispara cuando el store local se actualiza, o que hay un `useEffect` con dependencia en `user` que no tiene guard contra actualizaciones idempotentes.
## Fix
### Paso 1 — Diagnosticar dónde está el loop
Localizar el `useEffect` o callback que lee de la tabla `users` después de auth state change. Buscar en:
- `AuthContext.tsx` o equivalente
- El hook `useAuth`
- Cualquier `useEffect` que tenga `user` o `authUser` como dependencia y haga una query a `users`
### Paso 2 — Separar el flujo de "save perfil" del flujo de auth state change
El problema es que `updateUser` (actualización del store local) está acoplado al mecanismo de re-lectura de la DB. La solución es desacoplarlos:
**Opción A — Flag de re-entrada (más simple):**
Agregar un ref `isUpdatingProfile` que se activa durante el save y previene que el `onAuthStateChange` re-lea de la DB mientras está activo:
```ts
const isUpdatingProfile = useRef(false)
// En el save del perfil:
const saveProfile = async ({ name, company }) => {
isUpdatingProfile.current = true
try {
await supabase.from('users').update({ name, company }).eq('id', user.id)
// Actualizar store local directamente, sin pasar por onAuthStateChange:
setUser(prev => prev ? { ...prev, name, company } : prev)
} finally {
isUpdatingProfile.current = false
}
}
// En onAuthStateChange o el useEffect que re-lee la DB:
if (isUpdatingProfile.current) return // ← guard de re-entrada
```
**Opción B — Actualizar solo el estado local sin re-leer DB (más limpia):**
En el save del perfil, **no** re-disparar el mecanismo de `onAuthStateChange`. Solo actualizar el estado local con los valores que el usuario acaba de escribir — ya los tenés, no hace falta volver a leerlos de la DB:
```ts
const saveProfile = async ({ name, company }) => {
await supabase.from('users').update({ name, company }).eq('id', user.id)
// Actualizar el AuthUser en el store directamente con los valores conocidos.
// NO llamar a ninguna función que re-lea de la DB.
setCurrentUser(prev => prev ? { ...prev, name, company } : prev)
// Cerrar el Sheet.
setProfileSheetOpen(false)
}
```
Para que esto funcione, `setCurrentUser` (o equivalente) debe ser una actualización directa del estado, sin side effects que disparen re-lectura.
**Recomendación:** Opción B. Es más predecible: el save actualiza la DB y el estado local con los valores exactos que el usuario confirmó. No hay queries adicionales, no hay riesgo de loop.
### Paso 3 — Verificar que el `useEffect` que lee de `users` tiene guards adecuados
El `useEffect` o callback que lee `company` y `name` de la tabla `users` al detectar un cambio de auth debería ejecutarse solo en eventos genuinos de auth (login, token refresh), no en cada actualización del objeto `AuthUser` local.
Verificar que la dependencia del efecto es el **ID de sesión o el evento de auth**, no el objeto `user` completo (que cambia cada vez que lo actualizamos localmente):
```ts
// ❌ Loop: se re-ejecuta cada vez que user cambia, incluyendo el update local:
useEffect(() => {
if (user) fetchCompanyFromDB(user.id)
}, [user])
// ✅ Correcto: solo se ejecuta cuando cambia la sesión real:
useEffect(() => {
if (sessionId) fetchCompanyFromDB(userId)
}, [sessionId]) // sessionId es estable entre updates locales
```
---
## Criterios de validación
- [ ] Guardar nombre en el Sheet → app no se congela → nombre actualizado en el dropdown
- [ ] Guardar empresa en el Sheet → app no se congela → empresa aparece en el dropdown
- [ ] Cmd+R después de guardar → los valores persisten (están en Supabase)
- [ ] Navegar a otra página y volver → los valores siguen correctos
- [ ] `npm run build` limpio
- [ ] `npm run test` — 528+ Vitest verdes
- [ ] **Validación visual del director** antes del OK formal

View File

@@ -0,0 +1,104 @@
# Sprint 5 — Etapa 4C: Fix definitivo — contención de processLock al guardar perfil
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase.
Etapas 13B completadas con OK formal. Etapa 4 completada con una excepción: guardar perfil congela la app indefinidamente.
Etapa 4B intentó corregirlo con dos fixes (guard en `onAuthStateChange`, useEffect de ProfileSheet con `[open]`). Esos fixes fueron insuficientes — el freeze persiste.
Etapa 4D (diagnóstico) confirmó la causa raíz real. Esta etapa la resuelve.
---
## Causa raíz confirmada
**Archivo:** `src/features/library/LibraryPage.tsx` (aprox. línea 757)
```ts
useEffect(() => {
if (!user) return
load() // ← llama getSessionSafe() → intenta adquirir processLock
}, [load, user]) // ← "user" es el objeto completo, cambia de referencia en cada update
```
**Secuencia del freeze:**
1. Usuario guarda perfil → `ProfileSheet.tsx` llama `updateUser({ name, company })`
2. `updateUser` hace `setUser(prev => ({ ...prev, name, company }))` → nueva referencia del objeto `user`
3. `LibraryPage` detecta el cambio en `user` → dispara `load()`
4. `load()` llama `getSessionSafe()` → intenta adquirir `processLock`
5. En paralelo, el `supabase.from('users').update(...)` de ProfileSheet ya tiene o está esperando `processLock` para el token refresh
6. Contención → uno de los dos queda esperando indefinidamente → freeze
**Evidencia del diagnóstico (commit dd361b1):**
Los logs en producción muestran la secuencia hasta "local state updated" y luego silencio total — exactamente el momento en que `setUser(...)` dispara el useEffect de LibraryPage.
---
## Fix — una línea
**En `src/features/library/LibraryPage.tsx`**, cambiar la dependencia del useEffect de `user` a `user?.id`:
```ts
// ANTES (fires en cada cambio de referencia del objeto user):
useEffect(() => {
if (!user) return
load()
}, [load, user])
// DESPUÉS (fires solo cuando cambia el ID — distinto usuario o logout/login):
useEffect(() => {
if (!user) return
load()
}, [load, user?.id])
```
**Por qué funciona:** `user?.id` es un primitivo estable (string). No cambia cuando `updateUser` crea un nuevo objeto con el mismo `id` pero distinto `name` o `company`. La biblioteca solo se recarga cuando hay un usuario genuinamente diferente — que es el único caso donde tiene sentido recargar.
---
## Limpieza — remover logs de diagnóstico
El commit `dd361b1` agregó `console.log` temporales en `ProfileSheet.tsx` para el diagnóstico. Removerlos todos antes del commit final.
Buscar y eliminar cualquier línea `console.log` con prefijo `[ProfileSheet]` en `src/components/ProfileSheet.tsx`.
---
## Pasos de implementación
1. Abrir `src/features/library/LibraryPage.tsx`
2. Localizar el `useEffect` con dependencia `[load, user]` que llama `load()` (aprox. línea 757)
3. Cambiar `user``user?.id` en el array de dependencias
4. Abrir `src/components/ProfileSheet.tsx`
5. Remover todos los `console.log` temporales del diagnóstico
6. `npm run build` — 0 errores TypeScript
7. `npm run test` — 528+ Vitest verdes
8. **`git push origin main`** — obligatorio antes de validar en producción (Dokploy no autodeploy)
9. Commit: `fix(sprint-5/etapa-4c): library useEffect usa user.id no user ref — corta contención processLock al guardar perfil`
---
## Qué NO entra en esta etapa
- Cambios en `processLock`, `getSessionSafe()`, ni en el sistema de auth
- Cambios en `ProfileSheet.tsx` más allá de remover los console.log
- Refactoring de `LibraryPage` más allá del fix de la dependencia
- Ninguna feature nueva
---
## Criterios de validación
- [ ] Guardar nombre en el Sheet → app no se congela → nombre actualizado en el dropdown inmediatamente
- [ ] Guardar empresa en el Sheet → app no se congela → empresa aparece en el dropdown
- [ ] Cmd+R después de guardar → los valores persisten (están en Supabase)
- [ ] Navegar a la biblioteca y volver → la lista de procesos sigue correcta
- [ ] No hay `console.log` de diagnóstico en la consola del browser
- [ ] `npm run build` limpio
- [ ] `npm run test` — 528+ Vitest verdes
- [ ] **`git push origin main` ejecutado** antes de validar en producción
- [ ] **Validación visual del director** antes del OK formal

View File

@@ -0,0 +1,189 @@
# Sprint 5 — Etapa 4D: Fix definitivo — deadlock en onAuthStateChange
## Causa raíz real (auditada en código fuente de supabase-js v2.106.2)
La línea 3213 de `GoTrueClient.js` documenta explícitamente:
> **"A callback can be an `async` function and it runs synchronously during the processing of the changes causing the event. You can easily create a dead-lock by using `await` on a call to another method of the Supabase library."**
`SupabaseAuthService.onAuthStateChange` viola exactamente este contrato. El callback hace `await supabase.from('users').select('company, name')` **desde dentro del callback de auth**, que corre con el lock interno de GoTrueClient activo (`lockAcquired = true`).
La cadena de deadlock:
```
INITIAL_SESSION fires
→ GoTrueClient adquiere lock (lockAcquired = true)
→ fn() (operación auth) corre → llama _notifyAllSubscribers
→ _notifyAllSubscribers await nuestro callback (via Promise.all)
→ nuestro callback llama supabase.from('users').select(...)
→ select() necesita token → llama getSession() → llama _acquireLock()
→ _acquireLock ve lockAcquired = true → entra al path REENTRANT (sin timeout)
→ path reentrant espera a fn() → fn() espera al callback → callback espera al select → DEADLOCK CIRCULAR
→ lockAcquired queda true PARA SIEMPRE
→ toda llamada posterior a getSession() cuelga indefinidamente
→ incluye supabase.from('users').update() del perfil → app freezes
```
Los tres fixes anteriores (guard en onAuthStateChange, useEffect [open], user?.id en LibraryPage) no resolvieron el problema porque ninguno tocó la causa real: queries de Supabase dentro del callback de auth.
---
## Fix — arquitectura correcta
El callback de `onAuthStateChange` debe ser **sincrónico en su lógica de auth**: recibir el evento, construir el AuthUser básico, llamar `callback()`. Los side effects que necesitan DB (upsert, lectura de company/name) se ejecutan **fuera del lock**, en un `useEffect` de React.
### Cambio 1 — `src/auth/SupabaseAuthService.ts`
Eliminar TODAS las llamadas a `supabase.from(...)` del interior del callback. El callback solo llama `callback(buildAuthUser(session.user))`:
```ts
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => { // ← NO async: no necesita await
try {
if (!session?.user) {
if (event !== 'INITIAL_SESSION') callback(null)
return
}
if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN') return
// Llamar callback INMEDIATAMENTE con datos básicos del token OAuth.
// NO hacer queries a Supabase desde aquí — el lock de auth está activo
// y cualquier llamada reentrante causaría deadlock permanente (ver GoTrueClient.js L3213).
// La sincronización con la tabla users (company, name, upsert) ocurre en AuthContext.
callback(this.buildAuthUser(session.user))
} catch (err) {
console.error('Error en onAuthStateChange:', err)
if (session?.user) callback(this.buildAuthUser(session.user))
else if (event !== 'INITIAL_SESSION') callback(null)
}
}
)
return () => subscription.unsubscribe()
}
```
Agregar también un método público en `SupabaseAuthService` para la sincronización con DB (usado desde AuthContext):
```ts
async syncProfileFromDB(userId: string): Promise<Pick<AuthUser, 'name' | 'company'> | null> {
const { data } = await supabase
.from('users')
.select('company, name')
.eq('id', userId)
.maybeSingle()
if (!data) return null
return {
name: (data.name as string | null) ?? undefined as unknown as string,
company: (data.company as string | null) ?? undefined,
}
}
async upsertUserOnLogin(user: AuthUser): Promise<void> {
const { error } = await supabase.from('users').upsert(
{
id: user.id,
name: user.name,
avatar_url: user.avatarUrl ?? null,
platform_role: user.platformRole,
},
{ onConflict: 'id', ignoreDuplicates: true }
)
if (error) console.error('Error al crear usuario en DB:', error)
}
```
### Cambio 2 — `src/auth/AuthContext.tsx`
Agregar un `useEffect([user?.id])` que sincroniza con la tabla `users` DESPUÉS de que el auth se estableció — fuera del lock. También necesita `import { supabase } from '@/lib/supabase'`.
```tsx
// Después del useEffect existente que suscribe a onAuthStateChange:
// Sincronización con tabla users FUERA del lock de auth.
// Se ejecuta al cambiar de usuario (login/logout), NO en cada updateUser().
// user?.id es estable: no cambia cuando se edita nombre o empresa.
useEffect(() => {
if (!user?.id) return
;(async () => {
try {
// Upsert idempotente: crea la fila si no existe (primer login),
// ignora si ya existe (no sobreescribe ediciones posteriores).
await authService.upsertUserOnLogin(user)
// Leer company y name editado desde la tabla (pueden diferir del user_metadata de Google).
const profile = await authService.syncProfileFromDB(user.id)
if (profile) {
setUser((prev) =>
prev?.id === user.id
? {
...prev,
company: profile.company,
name: profile.name ?? prev.name,
}
: prev
)
}
} catch (err) {
console.error('Error al sincronizar perfil:', err)
}
})()
}, [user?.id]) // eslint-disable-line react-hooks/exhaustive-deps
```
**Comportamientos garantizados:**
- Login inicial → callback establece usuario básico → useEffect sincroniza con DB ✓
- Page refresh → idem ✓
- Profile save → `updateUser()` crea nueva referencia pero `user?.id` no cambia → useEffect NO dispara → datos locales no sobreescritos ✓
- Token refresh (TOKEN_REFRESHED) → callback retorna inmediatamente → useEffect no dispara ✓
- Logout → `user?.id` = null → useEffect hace early return ✓
- Re-login → `user?.id` cambia → useEffect re-sincroniza ✓
---
## Cambios en `src/auth/types.ts`
Agregar `syncProfileFromDB` y `upsertUserOnLogin` a la interfaz `AuthService` si existe, o dejar como métodos concretos en `SupabaseAuthService` sin agregar a la interfaz (preferible si la interfaz es abstracta).
Verificar que `AuthService` interface no requiera los métodos nuevos — son implementación concreta de Supabase.
---
## Qué NO entra en esta etapa
- Cambios en ProfileSheet, LibraryPage, router.tsx
- Cambios en el sistema de simulación o heatmap
- Ninguna feature nueva
---
## Orden de ejecución
1. Modificar `SupabaseAuthService.ts`:
- Convertir callback de `onAuthStateChange` a no-async
- Eliminar upsert y select del interior del callback
- Agregar métodos `syncProfileFromDB()` y `upsertUserOnLogin()`
2. Modificar `AuthContext.tsx`:
- Agregar import de `supabase` si no existe
- Agregar `useEffect([user?.id])` con la lógica de sincronización
3. `npm run build` — 0 errores TypeScript
4. `npm run test` — 528+ Vitest verdes
5. **`git push origin main`** — obligatorio antes de validar en producción
6. Commit: `fix(sprint-5/etapa-4d): mover queries DB fuera de onAuthStateChange — corta deadlock lockAcquired permanente`
---
## Criterios de validación
- [ ] Guardar nombre en el Sheet → app NO se congela → nombre actualizado en el dropdown
- [ ] Guardar empresa → app NO se congela → empresa aparece en el dropdown
- [ ] Cmd+R después de guardar → los valores persisten (están en Supabase)
- [ ] Al cargar la app (page refresh) → company y name se leen correctamente desde la DB
- [ ] Primer login (cuenta nueva) → usuario creado en tabla users ✓
- [ ] Token refresh en background → no causa ningún re-render ni error
- [ ] No hay `console.error` de deadlock en la consola del browser
- [ ] `npm run build` limpio
- [ ] `npm run test` — 528+ Vitest verdes
- [ ] **`git push origin main` ejecutado** antes de validar en producción
- [ ] **Validación visual del director** antes del OK formal

View File

@@ -0,0 +1,227 @@
# Sprint 5 — Etapa 4E: Fix definitivo del freeze al guardar perfil
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Supabase v2.106.2.
Etapas 4B, 4C, 4D intentaron corregir el freeze al guardar perfil. Ninguna resolvió el problema. Esta etapa identifica y corrige la causa raíz real.
---
## Causa raíz real — auditada en código fuente de `@supabase/auth-js`
### Componente 1: `getSessionSafe()` abandona Promises en `PROCESS_LOCKS`
`getSessionSafe()` en `src/lib/supabase.ts` está implementada así:
```ts
await Promise.race([
supabase.auth.getSession(), // Promise A — va al chain de processLock
new Promise((resolve) => setTimeout(resolve, 4000)), // timeout 4s
])
```
Cuando el timeout gana, `getSessionSafe()` resuelve. **Pero la Promise A de `getSession()` sigue corriendo en background.** Internamente, `getSession()` llamó `processLock(name, acquireTimeout, fn_session)`, que actualizó `PROCESS_LOCKS[name] = op_A` **antes de ceder el hilo**. La operación `op_A` está en vuelo (token refresh via red) con `PROCESS_LOCKS[name]` apuntando a ella.
### Componente 2: `lockAcquireTimeout = 5000ms` demasiado corto
El cliente Supabase usa `lockAcquireTimeout = 5000ms` por defecto. Cada llamada a `getSession()` pasa ese timeout a `processLock(name, 5000, fn)`.
### La cadena del freeze
```
t=0 → library-store.load() llama getSessionSafe()
→ getSession() → processLock → PROCESS_LOCKS[name] = op_A
→ op_A empieza: token refresh (request HTTP a Supabase Auth)
t=4s → getSessionSafe() timeout gana → resuelve
→ op_A sigue corriendo en background (PROCESS_LOCKS[name] = op_A, PENDIENTE)
t=4s → library queries lanzan: supabaseGroupRepo.getAll() etc.
→ _getAccessToken() → getSession() → _acquireLock(5000, fn_B)
→ processLock(5000): previousOperation = op_A (aún pendiente)
→ espera hasta que op_A resuelva... o hasta 5s
t=9s → ProcessLockAcquireTimeoutError para op_B
→ PROCESS_LOCKS[name] = wrapper_B
→ wrapper_B hace: await previousOperation (= op_A, AÚN COLGADA)
→ wrapper_B nunca resuelve mientras op_A esté colgada
t=X → Usuario abre ProfileSheet → hace click en Guardar
→ supabase.from('users').update() → getSession() → processLock
→ previousOperation = wrapper_B (NUNCA RESUELVE)
→ FREEZE PERMANENTE
```
La 4D fue 100% correcta: el callback de `onAuthStateChange` ya no tiene queries. Pero el freeze viene de esta cadena completamente diferente, que ocurre en `getSessionSafe()` de los stores.
---
## Fix — 4 archivos
### Cambio 1: `src/lib/supabase.ts`
Dos cambios:
**A) Aumentar `lockAcquireTimeout` a 30s:**
```ts
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
lock: processLock,
// Aumentado de 5000 (default) a 30000ms.
// Con 5s, un token refresh lento dispara ProcessLockAcquireTimeoutError,
// cuyo handler hace await previousOperation (colgada) → chain bloqueado permanente.
// 30s da margen para refresh en redes lentas o cold starts de Supabase Auth.
lockAcquireTimeout: 30000,
},
})
```
**B) Eliminar `getSessionSafe()`:**
Eliminar TODA la función `getSessionSafe()` — declaración y export. No dejar ni el tipo, ni un stub.
La función tenía un problema de diseño: el `Promise.race([getSession(), timeout])` abandona la Promise de `getSession()` cuando gana el timeout. La Promise abandonada deja una operación en `PROCESS_LOCKS[name]` que bloquea el chain.
### Cambio 2: `src/store/library-store.ts`
Eliminar la línea `await getSessionSafe()` y el import correspondiente:
```ts
// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'
// ELIMINAR esta línea en load():
await getSessionSafe()
```
No reemplazar con nada. Las queries a Supabase ya llaman `getSession()` internamente vía `_getAccessToken()`, que espera `initializePromise` automáticamente. El `Promise.race` de 15s que ya existe es suficiente como safety net general.
### Cambio 3: `src/store/process-store.ts`
Mismo tratamiento — hay 2 usos de `getSessionSafe()` (líneas 41 y 57):
```ts
// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'
// ELIMINAR ambas líneas:
await getSessionSafe() // una en loadProcess(), otra en loadActivities()
```
No reemplazar. Misma razón que en library-store.
### Cambio 4: `src/features/report/useReportData.ts`
Mismo tratamiento — 1 uso:
```ts
// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'
// ELIMINAR la línea:
await getSessionSafe()
```
### Cambio 5: `src/components/ProfileSheet.tsx`
Agregar timeout de 10s + estado de error visual. La red de seguridad: si en algún caso extremo la operación tarde más de 10s, el botón se re-habilita y se muestra un mensaje, en lugar de quedar congelado indefinidamente.
```tsx
const [saveError, setSaveError] = useState<string | null>(null)
useEffect(() => {
if (open && user) {
setName(user.name)
setCompany(user.company ?? '')
setSaveError(null) // ← limpiar error al abrir
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
async function handleSave() {
if (!user || !name.trim()) return
setSaving(true)
setSaveError(null)
try {
const savePromise = supabase
.from('users')
.update({ name: name.trim(), company: company.trim() || null })
.eq('id', user.id)
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('La operación tardó demasiado. Revisá tu conexión e intentá de nuevo.')),
10_000
)
)
const { error } = await Promise.race([savePromise, timeoutPromise])
if (error) throw error
updateUser({ name: name.trim(), company: company.trim() || undefined })
onOpenChange(false)
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Error al guardar')
} finally {
setSaving(false)
}
}
```
En el JSX del Sheet, mostrar el error si existe (después del bloque de inputs, antes del footer):
```tsx
{saveError && (
<p className="text-xs text-destructive mt-2">{saveError}</p>
)}
```
---
## Verificación — qué buscar en el código fuente de Supabase
Si querés confirmar antes de implementar, estos son los puntos exactos a verificar en `node_modules/@supabase/auth-js/dist/module/`:
- `locks.js`: función `processLock` — el `catch (e instanceof LockAcquireTimeoutError)` hace `await previousOperation` → si `previousOperation` nunca resuelve, el chain queda bloqueado
- `GoTrueClient.js` línea ~25: `lockAcquireTimeout: 5000` — el default que se pisa con el cambio en `createClient()`
- `GoTrueClient.js` línea ~2239: `getSession()` usa `this.lockAcquireTimeout` (no -1)
---
## Qué NO entra
- Cambios en `AuthContext.tsx` o `SupabaseAuthService.ts` (la 4D fue correcta)
- Cambios en el sistema de simulación, heatmap, o ComparisonTable
- Ninguna feature nueva
- No agregar nuevos `getSessionSafe()` equivalentes en ningún lado
---
## Orden de ejecución
1. `src/lib/supabase.ts`: agregar `lockAcquireTimeout: 30000`, eliminar `getSessionSafe()`
2. `src/store/library-store.ts`: eliminar import + `await getSessionSafe()`
3. `src/store/process-store.ts`: eliminar import + ambos `await getSessionSafe()`
4. `src/features/report/useReportData.ts`: eliminar import + `await getSessionSafe()`
5. `src/components/ProfileSheet.tsx`: agregar `saveError` state + timeout 10s + UI
6. `npm run build` — debe dar 0 errores TypeScript
7. `npm run test` — 528+ Vitest verdes
8. `git push origin main` — OBLIGATORIO antes de validar en producción
9. Commit: `fix(sprint-5/etapa-4e): lockAcquireTimeout 30s + eliminar getSessionSafe — corta freeze en guardar perfil`
---
## Criterios de validación
- [ ] Guardar nombre en ProfileSheet → app NO se congela → nombre actualizado en dropdown
- [ ] Guardar empresa → app NO se congela → empresa aparece en dropdown
- [ ] Cmd+R después de guardar → valores persisten (Supabase)
- [ ] Biblioteca carga correctamente después de eliminar `getSessionSafe()` de library-store
- [ ] Workspace carga correctamente después de eliminar `getSessionSafe()` de process-store
- [ ] Reporte carga correctamente después de eliminar `getSessionSafe()` de useReportData
- [ ] Si save tarda > 10s → botón se re-habilita → mensaje de error visible (no freeze)
- [ ] `npm run build` limpio
- [ ] `npm run test` — 528+ Vitest verdes
- [ ] **`git push origin main` ejecutado** antes de validar en producción
- [ ] **Validación visual del director** antes del OK formal

View File

@@ -0,0 +1,224 @@
# Sprint 5 — Etapa 4F: Fix definitivo save de perfil + ETAPA_4E_PROMPT pendiente
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Supabase v2.106.2.
Esta etapa resuelve DOS issues simultáneos:
1. **Save de perfil produce "sin llamadas de red + sin mensajes en consola"** al hacer click en Guardar
2. **Los Cambios 1-4 del ETAPA_4E_PROMPT.md nunca fueron aplicados al código** — solo se commitió el archivo del prompt
---
## Estado actual del código (ya verificado — no requiere re-lectura inicial)
- `src/components/ProfileSheet.tsx`: ya usa `fetch()` nativo con token de localStorage. ✅ NO tocar este archivo salvo el Cambio 3 de abajo.
- `src/lib/supabase.ts`: `getSessionSafe()` sigue presente, `lockAcquireTimeout` NO configurado. ❌
- `src/store/library-store.ts`: sigue con `await getSessionSafe()`. ❌
- `src/store/process-store.ts`: sigue con `await getSessionSafe()` (2 ocurrencias). ❌
- `src/features/report/useReportData.ts`: sigue con `await getSessionSafe()`. ❌
---
## Causa raíz: botón deshabilitado por `name.trim() === ''`
### Diagnóstico
`ProfileSheet.tsx` tiene:
```tsx
<Button onClick={handleSave} disabled={!name.trim() || saving}>
```
"Sin llamadas de red Y sin mensajes en consola" = el botón está deshabilitado. Click en un botón disabled no dispara ningún event handler — comportamiento nativo del browser, sin eventos, sin logs.
El botón está deshabilitado cuando `name.trim() === ''`. El `name` state se inicializa en `useEffect([open])` con `setName(user.name)`.
### Por qué `user.name` es cadena vacía
**En `SupabaseAuthService.buildAuthUser()`:**
```ts
name: supabaseUser.user_metadata?.full_name as string ?? email,
```
El operador `??` solo hace fallback con `null` o `undefined`. NO hace fallback con cadena vacía.
Si Google devuelve `full_name: ""`, entonces `"" ?? email` = `""``user.name = ""`.
**La cadena de propagación:**
```
buildAuthUser() → name = "" (Google devuelve full_name: "")
upsertUserOnLogin() → inserta name="" en tabla users (ignoreDuplicates: true, primer login)
syncProfileFromDB() → lee name="" del DB, lo retorna como ""
AuthContext setUser → profile.name ?? prev.name = "" ?? prev.name = "" ← ?? no hace fallback!
ProfileSheet useEffect → setName("") → name state = "" → botón disabled
```
Mismo patrón en `getUserFromStorage()` del `AuthContext.tsx`.
---
## Cambios a aplicar — 7 archivos
### Cambio 1: `src/auth/SupabaseAuthService.ts`
**A) En `buildAuthUser`**, cambiar `?? email` por `|| email`:
```ts
// ANTES:
name: supabaseUser.user_metadata?.full_name as string ?? email,
// DESPUÉS:
name: (supabaseUser.user_metadata?.full_name as string | undefined) || email,
```
El operador `||` trata `""`, `null` y `undefined` como ausentes, haciendo fallback al email.
**B) En `syncProfileFromDB`**, cambiar `?? undefined` por `|| undefined`:
```ts
// ANTES:
name: (data.name as string | null) ?? undefined as unknown as string,
// DESPUÉS:
name: ((data.name as string | null | undefined) || undefined) as unknown as string,
```
Esto trata tanto `null` como `""` como ausentes (retorna `undefined`), evitando que un nombre vacío en DB se propague al estado del usuario.
### Cambio 2: `src/auth/AuthContext.tsx`
**A) En `getUserFromStorage()`**, mismo fix `|| email`:
```ts
// ANTES:
name: (u.user_metadata?.full_name as string) ?? email,
// DESPUÉS:
name: (u.user_metadata?.full_name as string | undefined) || email,
```
**B) En el `useEffect([user?.id])` IIFE**, cambiar `??` por `||` para que cadena vacía del DB también haga fallback:
```ts
// ANTES:
? { ...prev, company: profile.company, name: profile.name ?? prev.name }
// DESPUÉS:
? { ...prev, company: profile.company, name: profile.name || prev.name }
```
### Cambio 3: `src/components/ProfileSheet.tsx`
**En `useEffect([open])`**, agregar fallback a email:
```ts
// ANTES:
setName(user.name)
// DESPUÉS:
setName(user.name || user.email || '')
```
Aunque los Cambios 1-2 previenen que `user.name` llegue vacío en sesiones nuevas, este fallback protege a usuarios cuya fila en DB ya tiene `name = ""` de sesiones anteriores. Usando el email como fallback, el campo siempre tiene un valor no-vacío → botón habilitado.
### Cambio 4: `src/lib/supabase.ts`
Dos cambios (del ETAPA_4E_PROMPT.md Cambio 1):
**A) Agregar `lockAcquireTimeout: 30000`:**
```ts
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
lock: processLock,
// Aumentado de 5000 (default) a 30000ms.
// Con 5s, un token refresh lento dispara ProcessLockAcquireTimeoutError,
// cuyo handler hace await previousOperation (colgada) → chain bloqueado permanente.
// 30s da margen para refresh en redes lentas o cold starts de Supabase Auth.
lockAcquireTimeout: 30000,
},
})
```
**B) Eliminar TODA la función `getSessionSafe()`** — declaración y export. No dejar ni stub ni comentario.
La función abandona la Promise de `getSession()` cuando gana el timeout. La Promise abandonada deja una operación en `PROCESS_LOCKS[name]` que bloquea el chain.
### Cambio 5: `src/store/library-store.ts`
```ts
// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'
// ELIMINAR esta línea en load():
await getSessionSafe()
```
No reemplazar con nada. Las queries a Supabase llaman `getSession()` internamente vía `_getAccessToken()` que espera `initializePromise` automáticamente.
### Cambio 6: `src/store/process-store.ts`
```ts
// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'
// ELIMINAR ambas líneas (en loadProcess() y en loadActivities()):
await getSessionSafe()
```
### Cambio 7: `src/features/report/useReportData.ts`
```ts
// ELIMINAR este import:
import { getSessionSafe } from '@/lib/supabase'
// ELIMINAR la línea:
await getSessionSafe()
```
---
## Lo que NO entra
- NO agregar logs de debug
- NO cambiar la estrategia de ProfileSheet (fetch nativo con localStorage token — es correcta, mantener)
- NO implementar el "Cambio 5" del ETAPA_4E_PROMPT.md (ProfileSheet con supabase client + timeout) — ya está mejor con fetch nativo
- NO cambios en simulación, heatmap, ComparisonTable, ni features nuevas
- NO cambios en `AuthContext.tsx` más allá de los especificados en Cambio 2
---
## Orden de ejecución
1. Leer los 7 archivos antes de editar para confirmar estado actual
2. Aplicar Cambio 1 (`SupabaseAuthService.ts`)
3. Aplicar Cambio 2 (`AuthContext.tsx`)
4. Aplicar Cambio 3 (`ProfileSheet.tsx`)
5. Aplicar Cambio 4 (`supabase.ts`)
6. Aplicar Cambio 5 (`library-store.ts`)
7. Aplicar Cambio 6 (`process-store.ts`)
8. Aplicar Cambio 7 (`useReportData.ts`)
9. `npm run build` — debe dar 0 errores TypeScript
10. `npm run test` — todos los Vitest verdes
11. `git add -A && git commit -m "fix(sprint-5/etapa-4f): || fallback en name vacío + eliminar getSessionSafe + lockAcquireTimeout 30s"`
12. `git push origin main`**OBLIGATORIO antes de validar en producción**
---
## Criterios de validación
- [ ] `npm run build` limpio (0 errores TypeScript)
- [ ] `npm run test` — todos los Vitest verdes
- [ ] **`git push origin main` ejecutado**
- [ ] **Validación visual del director antes del OK formal**
Validación funcional (post-deploy, a cargo del director):
- [ ] Guardar nombre en ProfileSheet → app NO se congela → nombre actualizado en dropdown
- [ ] Guardar empresa → app NO se congela → empresa aparece en dropdown
- [ ] Cmd+R después de guardar → valores persisten (Supabase)
- [ ] Biblioteca carga correctamente (sin getSessionSafe)
- [ ] Workspace carga correctamente (sin getSessionSafe)
- [ ] Reporte carga correctamente (sin getSessionSafe)

View File

@@ -0,0 +1,273 @@
# Sprint 5 — Etapa 4: Procesos por recurso + campo empresa en perfil
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase.
Etapas 1, 2, 3 y 3B completadas con OK formal. Esta etapa agrega dos features pequeñas de producto.
---
## Feature A — Ver procesos asociados a un recurso
### Contexto actual
`ResourceCatalogPage` tiene 5 columnas: Recurso, Tipo, Costo/h, Creado por, Acciones. El botón Eliminar ya protege contra borrado de recursos en uso (verifica `activity_resource_assignments`), pero el usuario no puede ver qué procesos usan el recurso antes de intentar eliminarlo.
### Implementación
**Paso 1 — Nueva query en `resource-repo.ts`:**
Agregar un método `getProcessCountsPerResource()` que devuelva un map de `resourceId → cantidad de procesos distintos`. Implementar con una sola query (no una por recurso) para evitar N+1:
```ts
async getProcessCountsPerResource(): Promise<Record<string, number>> {
const { data, error } = await supabase
.from('activity_resource_assignments')
.select('resource_id, activity:activities!inner(process_id)')
if (error) throw error
if (!data) return {}
// Agregar client-side: contar procesos DISTINTOS por recurso
const processSets: Record<string, Set<string>> = {}
for (const row of data) {
const resourceId = row.resource_id
const processId = (row.activity as { process_id: string }).process_id
if (!processSets[resourceId]) processSets[resourceId] = new Set()
processSets[resourceId].add(processId)
}
return Object.fromEntries(
Object.entries(processSets).map(([k, v]) => [k, v.size])
)
}
```
**Paso 2 — Cargar los conteos en `ResourceCatalogPage`:**
Al cargar los recursos (`useEffect` de mount), también llamar a `getProcessCountsPerResource()` en paralelo:
```ts
const [resources, processCounts] = await Promise.all([
supabaseResourceRepo.getAllWithCreator(),
supabaseResourceRepo.getProcessCountsPerResource(),
])
```
Guardar `processCounts` en estado local del componente.
**Paso 3 — Nueva columna "Procesos" en la tabla:**
Agregar columna entre "Creado por" y "Acciones":
- Header: "Procesos"
- Si `processCounts[resource.id] === 0` o undefined: mostrar "—" en color muted
- Si `processCounts[resource.id] > 0`: mostrar badge clicable con el número
```tsx
{count > 0 ? (
<button
onClick={() => toggleExpanded(resource.id)}
className="inline-flex items-center gap-1 text-xs font-medium text-orange-600 bg-orange-50 hover:bg-orange-100 rounded-md px-2 py-0.5 transition-colors"
>
{count} {count === 1 ? 'proceso' : 'procesos'}
</button>
) : (
<span className="text-muted-foreground"></span>
)}
```
El color naranja InQ (`text-orange-600`, `bg-orange-50`) es correcto para indicar "hay uso activo".
**Paso 4 — Expansión inline con lista de procesos:**
Al hacer click en el badge, expandir una fila adicional debajo con los nombres de los procesos.
Para esto necesitás una query adicional que devuelva los nombres — la query anterior solo devuelve conteos. Agregar un segundo método en `resource-repo.ts`:
```ts
async getProcessesForResource(resourceId: string): Promise<{ id: string; name: string }[]> {
const { data, error } = await supabase
.from('activity_resource_assignments')
.select('activity:activities!inner(process:processes!inner(id, name))')
.eq('resource_id', resourceId)
if (error) throw error
if (!data) return []
// Deduplicar por proceso id
const seen = new Set<string>()
const result: { id: string; name: string }[] = []
for (const row of data) {
const process = (row.activity as any).process
if (!seen.has(process.id)) {
seen.add(process.id)
result.push({ id: process.id, name: process.name })
}
}
return result
}
```
Esta query se llama **on demand** (solo cuando el usuario expande la fila), no al cargar la página.
La fila expandida muestra:
- Lista de nombres de proceso, cada uno como link a `/workspace/:id`
- Usar `<Link>` de TanStack Router
```tsx
// Fila expandida (colspan completo de la tabla):
<tr>
<td colSpan={6} className="px-4 pb-3 pt-0">
<div className="flex flex-wrap gap-2">
{processes.map(p => (
<Link key={p.id} to="/workspace/$processId" params={{ processId: p.id }}
className="text-xs text-blue-600 hover:underline">
{p.name}
</Link>
))}
</div>
</td>
</tr>
```
---
## Feature B — Campo empresa en perfil de usuario
### Contexto actual
`AuthUser` en `/src/auth/types.ts` tiene: `id, email, name, avatarUrl?, platformRole`. Sin `company`.
`buildAuthUser()` construye el objeto desde OAuth `user_metadata` de Google. No lee la tabla `users` de Supabase directamente.
El AppHeader dropdown muestra: avatar + nombre + email + botón "Cerrar sesión".
### Implementación
**Paso 1 — Migración en Supabase:**
Crear archivo de migración `supabase/migrations/006_add_company_to_users.sql`:
```sql
ALTER TABLE users ADD COLUMN IF NOT EXISTS company text;
```
Ejecutar en Supabase Studio SQL Editor. Verificar que no rompe datos existentes (la columna es nullable sin default).
**Paso 2 — Extender `AuthUser`:**
En `/src/auth/types.ts`:
```ts
export interface AuthUser {
id: string
email: string
name: string
avatarUrl?: string
platformRole: 'platform_admin' | 'member' | 'guest'
company?: string // 🆕 nullable, viene de tabla users
}
```
**Paso 3 — Leer `company` desde la tabla `users`:**
`buildAuthUser()` actualmente solo usa `user_metadata` de OAuth. Para `company` necesitamos leer la tabla `users` de Supabase (que ya existe y se populó en Sprint 4).
La forma más limpia: después de que el usuario autentique, hacer una query puntual a `users` para obtener el `company`. Este fetch puede ir en el `onAuthStateChange` callback, justo después de construir el `AuthUser` base:
```ts
// En AuthContext o SupabaseAuthService, después de obtener el usuario de OAuth:
const { data: userRow } = await supabase
.from('users')
.select('company, name')
.eq('id', user.id)
.maybeSingle()
const authUser: AuthUser = {
...buildAuthUser(session.user),
company: userRow?.company ?? undefined,
// Si el nombre fue editado en la app (no solo el de Google), usar el de la tabla:
name: userRow?.name ?? buildAuthUser(session.user).name,
}
```
**Paso 4 — UI en AppHeader: mostrar empresa**
En el dropdown del AppHeader, agregar la empresa debajo del email si está definida:
```tsx
<div className="flex flex-col px-3 py-2 text-sm">
<span className="font-medium">{user.name}</span>
<span className="text-muted-foreground text-xs">{user.email}</span>
{user.company && (
<span className="text-muted-foreground text-xs mt-0.5">{user.company}</span>
)}
</div>
```
**Paso 5 — Edición de perfil:**
Agregar un item "Editar perfil" en el dropdown (antes de "Cerrar sesión"), que abre un `Sheet` (el mismo componente Sheet de shadcn/ui ya usado en `ResourceFormSheet`):
El Sheet de perfil tiene:
- **Nombre** — input editable (texto libre)
- **Empresa** — input editable (texto libre, placeholder "InQuality")
- **Email** — solo lectura (viene de Google OAuth, no editable)
- Botón "Guardar" — hace `UPDATE users SET name = ?, company = ? WHERE id = ?`
- Botón "Cancelar"
Al guardar: actualizar el store de auth local para que el AppHeader refleje el cambio sin recargar.
```ts
// En el repo o en el store, al guardar perfil:
await supabase
.from('users')
.update({ name, company })
.eq('id', user.id)
// Actualizar el AuthUser en el store local:
updateUser({ name, company })
```
`updateUser` debe existir en `AuthContext` o `useAuth` — si no existe, crearlo.
---
## Qué NO entra en esta etapa
- Cambios en la lógica de simulación o heatmap
- Gestión de roles o permisos de usuario
- Página de perfil separada (el Sheet del dropdown es suficiente)
- Campos adicionales más allá de nombre y empresa
---
## Orden de ejecución
1. Migración SQL `006_add_company_to_users.sql` — ejecutar en Supabase + crear archivo
2. Extender `AuthUser` con `company?` en `src/auth/types.ts`
3. Modificar `buildAuthUser` / `onAuthStateChange` para leer `company` y `name` de tabla `users`
4. AppHeader: mostrar empresa en dropdown si existe
5. AppHeader: agregar "Editar perfil" → Sheet con nombre/empresa editables
6. `resource-repo.ts`: agregar `getProcessCountsPerResource()` y `getProcessesForResource()`
7. `ResourceCatalogPage`: cargar conteos en paralelo con recursos, agregar columna "Procesos"
8. `ResourceCatalogPage`: expansión inline de fila con lista de procesos linkeados
9. `npm run build` — 0 errores TypeScript
10. `npm run lint` — 0 warnings nuevos
11. `npm run test` — mantener 528+ tests Vitest verdes
12. Commit: `feat(sprint-5/etapa-4): procesos por recurso + campo empresa en perfil`
---
## Criterios de validación
- [ ] Columna "Procesos" visible en `/recursos` con conteo correcto (verificar con un recurso usado en ≥1 proceso)
- [ ] Recurso sin asignaciones muestra "—" en la columna Procesos
- [ ] Click en el badge expande la lista de procesos con nombres y links funcionales
- [ ] Click en un proceso navega al workspace correcto
- [ ] Migración SQL ejecutada sin errores; `SELECT company FROM users` no falla
- [ ] AppHeader muestra empresa si está definida (probar editando desde el Sheet)
- [ ] Sheet de perfil: guardar nombre y empresa persiste en Supabase y se refleja en el dropdown
- [ ] Email en el Sheet es solo lectura
- [ ] 528+ tests Vitest verdes
- [ ] **Validación visual del director** antes del OK formal

View File

@@ -0,0 +1,229 @@
# Etapa 5 — Columna "Eficiencia de automatización" en ComparisonTable
**Sprint:** 5
**Fecha:** 2026-06-23
**Alcance:** reporte web únicamente. PDF explícitamente fuera del scope (ver decisión al final).
---
## Contexto
`ComparisonTable` (`src/features/report/ComparisonTable.tsx`) muestra actualmente 5 columnas:
icono automatable | Actividad | Costo actual | Costo automatizado | Ahorro | % ahorro.
La tabla ordena por ahorro descendente por defecto. Falta una métrica que permita **rankear actividades por valor de automatización** — no quién ahorra más en absoluto, sino cuál tiene mejor retorno considerando su peso en el proceso.
---
## Dos métricas (A/B para focus group con clientes)
Se muestran **dos columnas** nuevas, una al lado de la otra. La hipótesis es validar con clientes reales cuál agrega más valor en una sesión de consultoría:
| Columna | Fórmula | Interpretación |
|---|---|---|
| **Ahorro esperado** | `ahorro$ × executionProbability` | Cuánto se ahorra *en promedio* por instancia del proceso, ponderando la probabilidad de que la actividad se ejecute |
| **Eficiencia** | `(ahorro$ × executionProbability) / executionTimeMinutes` | Ahorro por minuto de proceso — prioriza actividades con alto ahorro relativo a su costo de tiempo |
Ambos datos ya existen en `ActivitySimResult`:
- `ahorro$` = `actualCost - automatedCost` (ya calculado en `ComparisonRow`)
- `executionProbability` = `ActivitySimResult.executionProbability`
- `executionTimeMinutes` = `ActivitySimResult.executionTimeMinutes`
**Reglas de null (aplican a ambas columnas):**
- Si la actividad no es automatable → `null` (mostrar "—")
- Si `ahorro$ <= 0``null`
- Para "Eficiencia": si además `executionTimeMinutes === 0``null`
> **Nota de modelo:** `executionProbability` es la probabilidad de ejecución de la actividad en una instancia del proceso (calculada por el motor BPMN a partir de los gateways), no la probabilidad de éxito de la automatización. Es el dato disponible hoy. En un eventual v2 se podría agregar una probabilidad de éxito por actividad.
---
## Implementación
### 1. Agregar las dos métricas al tipo `ComparisonRow`
```ts
interface ComparisonRow {
// ... campos existentes ...
expectedSavings: number | null // ahorro$ × executionProbability
efficiency: number | null // (ahorro$ × executionProbability) / executionTimeMinutes
}
```
En el cálculo de `rows` (useMemo):
```ts
const expectedSavings =
automatable && savings > 0
? savings * act.executionProbability
: null
const efficiency =
automatable && savings > 0 && act.executionTimeMinutes > 0
? (savings * act.executionProbability) / act.executionTimeMinutes
: null
```
`act` es `ActivitySimResult``executionProbability` y `executionTimeMinutes` ya están disponibles en `perActivityActual`.
### 2. Cambiar el estado de sort
Reemplazar `const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')` por:
```ts
const [sortBy, setSortBy] = useState<'savings' | 'expectedSavings' | 'efficiency'>('efficiency')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
```
Función de sort:
```ts
.sort((a, b) => {
const val = (row: ComparisonRow) => {
if (sortBy === 'efficiency') return row.efficiency ?? -Infinity
if (sortBy === 'expectedSavings') return row.expectedSavings ?? -Infinity
return row.savings
}
return (val(b) - val(a)) * (sortDir === 'desc' ? 1 : -1)
})
```
Filas con valor `null` siempre al final.
### 3. Headers de columna con sort y tooltip
Importar `Tooltip, TooltipContent, TooltipProvider, TooltipTrigger` desde `@/components/ui/tooltip`.
Patrón para cada columna nueva (repetir para "Ahorro esperado" y "Eficiencia"):
```tsx
<TableHead
className="text-xs text-right cursor-pointer text-amber-800/70 font-medium uppercase tracking-wide hover:text-amber-900"
onClick={() => {
const col = 'efficiency' // o 'expectedSavings'
if (sortBy === col) setSortDir(d => d === 'desc' ? 'asc' : 'desc')
else { setSortBy(col); setSortDir('desc') }
}}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center justify-end gap-1">
Eficiencia {/* o "Ahorro esperado" */}
<ArrowUpDown className={`h-3 w-3 ${sortBy === 'efficiency' ? 'opacity-80' : 'opacity-40'}`} />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-52 text-xs text-center">
{/* Para "Ahorro esperado": */}
Ahorro por instancia ponderado por probabilidad de ejecución de la actividad
{/* Para "Eficiencia": */}
Ahorro esperado dividido por tiempo de ejecución prioriza actividades con mayor retorno por minuto de proceso
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableHead>
```
La columna "Ahorro" existente también debe quedar clickeable para sort (mismo patrón, `sortBy === 'savings'`).
### 4. Celdas — versión wow: barra de progreso proporcional
Calcular máximos para cada métrica (dentro del useMemo de rows, después del sort):
```ts
const maxExpectedSavings = Math.max(
...rows.filter(r => r.expectedSavings !== null).map(r => r.expectedSavings as number), 0
)
const maxEfficiency = Math.max(
...rows.filter(r => r.efficiency !== null).map(r => r.efficiency as number), 0
)
```
Componente reutilizable para la celda con barra (puede ser una función local en el archivo):
```tsx
function MetricCell({ value, max, format }: { value: number | null; max: number; format: (v: number) => string }) {
if (value === null) return <TableCell className="text-xs text-right"><span className="text-slate-300"></span></TableCell>
return (
<TableCell className="text-xs text-right">
<div className="flex items-center justify-end gap-2">
<div className="w-14 h-1.5 rounded-full bg-slate-100 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500"
style={{ width: `${max > 0 ? (value / max) * 100 : 0}%` }}
/>
</div>
<span className="font-mono text-slate-700 text-xs w-14 text-right">
{format(value)}
</span>
</div>
</TableCell>
)
}
```
Formato de valores:
- "Ahorro esperado": `formatCurrency(value, currency)` — mismo helper que el resto de la tabla
- "Eficiencia": `value.toFixed(2)` + sufijo implícito (sin unidad visible, el tooltip explica)
### 5. Tests unitarios
Crear `tests/features/report/efficiency.test.ts`:
```ts
// Casos básicos para ambas métricas
// expectedSavings = savings × executionProbability
// efficiency = expectedSavings / executionTimeMinutes
// Edge cases:
// - savings ≤ 0 → null en ambas
// - automatable = false → null en ambas
// - executionTimeMinutes = 0 → efficiency null, expectedSavings puede ser válido
// - executionProbability = 0 → null en ambas (resultado sería 0, no rankeable)
// Ranking: filas nulas siempre al final del sort
// Sort por 'efficiency': fila nula < fila con efficiency 0.01
```
---
## Decisión de producto — focus group
Las dos columnas se implementan para **validar con clientes en talleres** cuál agrega más valor en contexto real de consultoría. No se elige una antes del focus group.
Cuando haya feedback suficiente, la siguiente decisión puede ser: mantener las dos, quedarse con una, o fusionarlas en una sola columna con toggle. Esa decisión es post Sprint 5.
---
## Scope estricto — lo que NO entra
- **PDF:** la tabla de comparación en PDF ya tiene 6 columnas en 138mm de ancho de contenido. Agregar una 7ª columna con barra de progreso no es viable en portrait sin truncar nombres de actividades. La columna de eficiencia queda fuera del PDF en esta etapa.
- Cambios en el modelo de datos (`ActivitySimResult`, `types.ts`): todos los datos necesarios ya existen.
- Cambios en el motor de simulación (`simulation.ts`): nada cambia allá.
- Cambios en `ReportPage.tsx` (salvo si hay props nuevas que pasar, que no hay).
- Cambios en `pdf-sections-roi.ts` ni en `buildComparisonTableConfig`.
---
## Criterios de validación
- [ ] Dos columnas nuevas visibles: "Ahorro esperado" y "Eficiencia", alineadas a la derecha
- [ ] Actividades no automatizables muestran "—" en ambas columnas
- [ ] Sort por defecto: mayor eficiencia primero (desc)
- [ ] Click en cada columna activa el sort por esa métrica; click nuevamente alterna asc/desc
- [ ] El ícono ArrowUpDown es más opaco en la columna de sort activa
- [ ] Barra de progreso proporcional en ambas columnas: el máximo = 100% del ancho
- [ ] Tooltip aparece al hacer hover en el header de cada columna nueva
- [ ] Tests unitarios de ambas fórmulas en Vitest: verdes, incluyendo edge cases
- [ ] `npm run lint` sin errores
- [ ] `npm run build` limpio
- [ ] Validación visual del director antes del OK formal
---
## Archivos a modificar
1. `src/features/report/ComparisonTable.tsx` — todo el trabajo de la feature
2. `tests/features/report/efficiency.test.ts` (nuevo) — tests unitarios de la fórmula
No modificar ningún otro archivo.

View File

@@ -0,0 +1,306 @@
# Etapa 6 — Limpieza técnica: E2E Supabase + flakiness + documentación
**Sprint:** 5
**Fecha:** 2026-06-23
**Alcance:** tests E2E, botón ActivityPanel, documentación de cierre de sprint.
---
## Contexto
Sprint 4 migró la persistencia de IndexedDB (Dexie) a Supabase. Dos specs E2E quedaron permanentemente rotos porque inyectan datos vía IndexedDB directamente:
- `tests/e2e/export-pdf.spec.ts`
- `tests/e2e/validate-dod-etapa4.spec.ts`
Adicionalmente, 3 specs fallan intermitentemente por timing al buscar el botón "Guardar cambios" antes de que esté habilitado.
Esta etapa no toca código de producto. Solo tests, un `data-testid` y documentación.
---
## 6a — Reescribir specs Dexie → Supabase
### El problema
Ambos specs usan `page.evaluate()` para abrir IndexedDB directamente y escribir datos:
```ts
// patrón antiguo — NO funciona con Supabase
const db = await new Promise<IDBDatabase>((res, rej) => {
const req = indexedDB.open('ProcessCostPlatform')
req.onsuccess = () => res(req.result)
// ...
})
```
Dexie/IndexedDB fue eliminado en Sprint 4. Las tablas `ProcessCostPlatform` no existen.
### La solución
Reemplazar la inyección de datos por llamadas a la API REST de Supabase usando el `service_role_key` (bypasa RLS). El patrón es:
```ts
// en beforeAll / dentro del test
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY)
const { data: activities } = await supabase
.from('activities')
.select('id, bpmn_element_id')
.eq('process_id', processId)
await supabase.from('activities').upsert(
activities.map(act => ({
id: act.id,
direct_cost_fixed: ACTUAL_COST[act.bpmn_element_id] ?? 200,
execution_time_minutes: 60,
automatable: AUTOMATABLE.has(act.bpmn_element_id),
automated_cost_fixed: AUTOMATABLE.has(act.bpmn_element_id) ? AUTO_COST : 0,
automated_time_minutes: AUTOMATABLE.has(act.bpmn_element_id) ? AUTO_TIME : 0,
}))
)
```
**Credenciales:** leer de `.env.test` (ya existe, gitignored). Agregar si no existe:
```
SUPABASE_SERVICE_ROLE_KEY=<service_role_key>
```
Ver `.env.example` para el formato esperado.
**Cleanup post-test:** en `afterAll`, borrar el proceso creado por el test (elimina en cascada actividades y simulaciones):
```ts
afterAll(async () => {
if (createdProcessId) {
await supabase.from('processes').delete().eq('id', createdProcessId)
}
})
```
### Estrategia de implementación
1. El flujo UI se mantiene igual: import BPMN → esperar workspace → capturar `processId` de la URL
2. **Reemplazar** el bloque de `page.evaluate(... indexedDB ...)` por llamadas Supabase directas (no desde el browser, sino desde el proceso de Node.js del test)
3. Después de inyectar los datos, hacer `await page.reload()` para que el store de Zustand recargue desde Supabase (mismo patrón que tenía antes)
4. Continuar con el flujo de simulación y export igual que antes
### Notas de esquema
Las columnas de Supabase usan snake_case. Los campos relevantes en `activities`:
- `direct_cost_fixed` (número, costo fijo por ejecución)
- `execution_time_minutes`
- `automatable` (boolean)
- `automated_cost_fixed`
- `automated_time_minutes`
- `process_id`
- `bpmn_element_id`
Verificar el esquema real ejecutando:
```ts
const { data } = await supabase.from('activities').select('*').limit(1)
console.log(Object.keys(data[0]))
```
antes de escribir el upsert definitivo.
Para el proceso, las columnas relevantes:
- `annual_frequency`
- `analysis_horizon_years`
- `automation_investment`
### 6a-extra — Diagnóstico y fix de `auth-setup.ts` (probable bug)
**Diagnóstico a realizar antes de continuar con 6a:**
`tests/e2e/setup/auth-setup.ts` guarda la sesión en localStorage bajo la clave:
```ts
const storageKey = `sb-${projectRef}-auth-token`
```
Sin embargo, `@supabase/auth-js` v2 usa la clave **`supabase.auth.token`** (constante `STORAGE_KEY` en `node_modules/@supabase/auth-js/dist/main/lib/constants.js`). Esta discrepancia existe desde Sprint 4 cuando se configuró el setup.
**Verificar:**
1. Correr `npx playwright test tests/e2e/sprint-4-smoke.spec.ts` y observar si los tests "Con sesión" realmente obtienen datos de Supabase o si pasan vacíos (sin auth real)
2. Ejecutar el test `app header shows user avatar` — si pasa sin auth real, hay algo raro
3. Leer la constante en `node_modules/@supabase/auth-js/dist/main/lib/constants.js` para confirmar la clave correcta
**Fix si se confirma el bug:**
En `auth-setup.ts`, cambiar:
```ts
const projectRef = new URL(supabaseUrl).hostname.split('.')[0]
const storageKey = `sb-${projectRef}-auth-token`
```
por:
```ts
const storageKey = 'supabase.auth.token'
```
**Importante:** si `auth-state.json` ya existe con la clave incorrecta, regenerarlo corriendo el setup tras el fix. El archivo es gitignored — puede regenerarse sin afectar a otros.
**Impacto:** si el bug existe, todos los specs con auth que leen datos de Supabase son inválidos (pasan porque no hay assertions que fallen con datos vacíos, no porque el auth funcione). Esto sería deuda crítica que afecta la confiabilidad de toda la suite E2E.
Si el bug **no** se confirma (la clave ya es correcta por alguna razón), documentarlo en un comentario en `auth-setup.ts` explicando por qué difiere de la constante de la librería.
---
## 6b — Fix flakiness "Guardar cambios"
### El problema
El botón en `ActivityPanel.tsx` (línea ~592) es:
```tsx
<Button
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar cambios' : 'Sin cambios'}
</Button>
```
No tiene `data-testid`. Los specs lo buscan con `page.getByRole('button', { name: /Guardar/i })` sin esperar a que esté habilitado. Cuando `isDirty = false` (estado inicial), el botón dice "Sin cambios" y está deshabilitado — Playwright lo encuentra pero falla al hacer click.
### Fix en `ActivityPanel.tsx`
Agregar `data-testid="save-activity-btn"` al botón:
```tsx
<Button
data-testid="save-activity-btn"
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar cambios' : 'Sin cambios'}
</Button>
```
### Fix en los specs afectados
Identificar los 3 specs que tienen el patrón flaky (buscar `getByRole('button', { name: /Guardar/i })`):
- `tests/e2e/payback-etapa-2.spec.ts` — función `configureActivity`
- `tests/e2e/export-roi.spec.ts` — función `configureActivity`
- Cualquier otro spec que use el mismo patrón
Reemplazar el patrón de click sin wait:
```ts
// ANTES (flaky)
const guardar = page.getByRole('button', { name: /Guardar/i })
await guardar.click()
await page.waitForTimeout(500)
// DESPUÉS (determinístico)
await page.waitForSelector('[data-testid="save-activity-btn"]:not([disabled])', { timeout: 10_000 })
await page.click('[data-testid="save-activity-btn"]')
// Esperar confirmación de guardado (desaparece el isDirty):
await page.waitForSelector('[data-testid="save-activity-btn"][disabled]', { timeout: 10_000 })
```
---
## 6c — Documentación de cierre de sprint
### BACKLOG.md — marcar como RESOLVED
Verificar si estos ítems siguen en estado pendiente y actualizarlos:
1. `[SPRINT-5] Indicador de simulación desactualizada` — implementado en Sprint 3
2. `[SPRINT-5] Edición inline de proceso/cliente` — implementado en Sprint 2
3. `[SPRINT-5] Controles de zoom BPMN` — implementado en Sprint 5 Etapa 2
4. `[SPRINT-5] Columna "Eficiencia de automatización"` — implementado en Sprint 5 Etapa 5
Para cada uno: cambiar el tag a `[RESOLVED]` y agregar la línea `**Sprint cerrado en:** Sprint N Etapa X`.
### STRATEGIC_DIRECTION.md — agregar cierre Sprint 4 y Sprint 5
Si el archivo existe, agregar una sección de cierre para ambos sprints:
**Sprint 4 (cerrado 2026-06-18):**
- Decisión estratégica: refactor arquitectural completo (Supabase + auth). Sin features de producto nuevas. Justificación: la deuda técnica de IndexedDB bloqueaba features colaborativas.
- Resultado: auth Google OAuth + Supabase, migración IndexedDB→PostgreSQL, RLS, catálogo global de recursos. ~550+ tests.
**Sprint 5 (cerrado 2026-06-23):**
- Decisión estratégica: retorno al rebalanceo normal (60-70% features web). UX polish + quick wins.
- Resultado: fix workspace loading, zoom BPMN, heatmap gradiente 4 stops + toggle, recursos por proceso, campo empresa, columna eficiencia, reescritura E2E Supabase.
### OBSERVATIONS.md — preparar para Sprint 6
Una vez validadas todas las etapas del Sprint 5:
1. Mover hallazgos pendientes a TECH_DEBT.md o BACKLOG.md según corresponda
2. Reemplazar el contenido con el encabezado limpio para Sprint 6:
```markdown
# OBSERVATIONS — Sprint 6
> Hallazgos descubiertos durante el sprint actual.
> ...
## Hallazgos activos
*(vacío)*
```
### TECH_DEBT.md — marcar RESOLVED los ítems resueltos en Etapa 6
- `[BACKLOG] E2E specs con Dexie directo``[RESOLVED]` con nota de la solución
- `[BACKLOG] E2E flakiness — "Guardar cambios" button timing``[RESOLVED]` con nota
---
## Criterios de validación
### Tests
- [ ] `npx playwright test tests/e2e/export-pdf.spec.ts` — verde (o skip explícito si el test user no está disponible en el entorno local, con comentario en el spec)
- [ ] `npx playwright test tests/e2e/validate-dod-etapa4.spec.ts` — verde
- [ ] Los 3 specs flaky de "Guardar cambios" corren 3 veces seguidas sin fallar
- [ ] `npx playwright test tests/e2e/sprint-4-smoke.spec.ts` — sin regresiones
- [ ] `npm run test` — mantener o superar conteo anterior de Vitest
### Código
- [ ] `data-testid="save-activity-btn"` presente en `ActivityPanel.tsx`
- [ ] `npm run lint` sin errores
- [ ] `npm run build` limpio
### Documentación
- [ ] BACKLOG.md actualizado
- [ ] STRATEGIC_DIRECTION.md con cierres Sprint 4 y 5
- [ ] OBSERVATIONS.md preparado para Sprint 6
### Validación del director
- [ ] Revisar los 3 archivos de documentación antes del OK formal
---
## Orden de ejecución recomendado
1. **Primero:** diagnóstico de `auth-setup.ts` (6a-extra) — determina si la suite E2E con auth funciona realmente
2. **Segundo:** fix de `auth-setup.ts` si aplica + regeneración de `auth-state.json`
3. **Tercero:** `data-testid` en ActivityPanel (6b) — bajo esfuerzo, sin riesgo
4. **Cuarto:** fix de timing en specs (6b) — depende del `data-testid`
5. **Quinto:** reescritura E2E Dexie→Supabase (6a) — depende de que auth funcione
6. **Último:** documentación (6c) — solo cuando todo lo técnico esté verde
---
## Archivos a modificar
1. `tests/e2e/setup/auth-setup.ts` — fix localStorage key (si se confirma el bug)
2. `tests/e2e/export-pdf.spec.ts` — reescribir inyección de datos (Dexie → Supabase)
3. `tests/e2e/validate-dod-etapa4.spec.ts` — reescribir inyección de datos (Dexie → Supabase)
4. `tests/e2e/payback-etapa-2.spec.ts` — fix timing guardar
5. `tests/e2e/export-roi.spec.ts` — fix timing guardar
6. `src/features/workspace/ActivityPanel.tsx` — agregar `data-testid`
7. `BACKLOG.md` — actualizar estados
8. `STRATEGIC_DIRECTION.md` — agregar cierres Sprint 4 y 5
9. `OBSERVATIONS.md` — preparar para Sprint 6
10. `TECH_DEBT.md` — marcar resueltos
No modificar archivos de código de producto (src/) salvo el `data-testid` en ActivityPanel.
---
## Nota operativa
Si los specs E2E de Supabase no corren en el entorno local (falta `SUPABASE_SERVICE_ROLE_KEY` en `.env.test`), documentar el procedimiento en `.env.example` y agregar un `test.skip` condicional con mensaje descriptivo en lugar de dejar los tests rojos. Un skip explícito es mejor que un test permanentemente rojo.

View File

@@ -0,0 +1,86 @@
# Informe — Resolución del freeze al guardar perfil (Sprint 5, Etapa 4)
**Fecha:** 2026-06-23
**Síntoma reportado:** al guardar nombre/empresa en el Sheet de "Editar perfil", la aplicación deja de responder a clicks en cualquier parte de la pantalla. Persistía tras múltiples deploys.
**Estado final:** resuelto. Confirmado en producción por el director.
---
## Resumen ejecutivo
Hubo **dos bugs independientes superpuestos** bajo el mismo síntoma ("la app se congela"):
1. **Contención de `processLock`** (Supabase Auth v2): operaciones de sincronización de sesión al login competían por un lock interno compartido con el guardado de perfil, pudiendo bloquearlo indefinidamente si el servidor tardaba.
2. **Bug de Radix UI / `react-remove-scroll`**: al cerrar el Sheet, el cleanup que restaura `pointer-events` del `<body>` no se ejecutaba de forma confiable, dejando toda la página no interactiva — salvo los elementos del canvas BPMN, que fijan `pointer-events: all` explícitamente vía su propio CSS y por eso seguían respondiendo.
El primero se resolvió en las etapas 4B4F. El segundo, que era la causa del síntoma final reportado, se resolvió en la etapa 4H (dos iteraciones).
---
## Cronología de intentos
| Etapa | Hipótesis | Fix aplicado | Resultado |
|---|---|---|---|
| 4B (`dd361b1`) | `LibraryPage` recargaba en cada referencia nueva de `user` | `useEffect` dep `user``user?.id` | Insuficiente — no era la causa raíz completa |
| 4D (`496c227`) | Deadlock documentado en GoTrueClient.js L3213: callback `async` de `onAuthStateChange` hace `await` de otra llamada Supabase mientras el lock interno está activo | Callback de `onAuthStateChange` pasa a ser síncrono; las queries de DB se mueven a un `useEffect` separado fuera del lock | Necesario pero no suficiente |
| 4B-bis (`3d3ae99`) | El guardado de perfil seguía pasando por `supabase.from().update()`, que adquiere el lock vía `getSession()` internamente | Reemplazo por `fetch` nativo con token leído de `localStorage` | Mitiga, pero introduce su propio problema (ver 4G) |
| 4B-bis (`602957b`) | Las queries de sync de perfil al login podían colgar el lock indefinidamente si el servidor no respondía | `AbortController` con timeout de 6s en esas queries | Buena práctica defensiva, no resuelve el síntoma final |
| 4E (`3ebfd65`) | `getSessionSafe()` (barrera con `Promise.race` + timeout) abandonaba la Promise de `getSession()` al ganar el timeout, dejando una operación huérfana ocupando el lock para siempre | `lockAcquireTimeout: 30000` + eliminación completa de `getSessionSafe()` en 4 archivos | Correcto, resolvía un freeze real pero distinto al reportado al final |
| 4F (`1c53299`) | El botón "Guardar" aparecía deshabilitado porque `user.name` llegaba como cadena vacía (Google devolvía `full_name: ""` y el operador `??` no hace fallback con `""`) | `??``||` en 4 puntos de la cadena de propagación del nombre | Corrige un bug real distinto (botón disabled, no freeze) |
| 4G (`7f1f833``a1a8b1c``985c655``aaf1f79`) | El propio fetch nativo, al cerrar el Sheet de forma optimista, dejaba un overlay de Radix "stuck" en el DOM | Patrón optimista (cerrar antes de persistir) + defer de `updateUser` 350ms para no interferir con la animación de cierre de Radix | Mitigaba parcialmente, no resolvía la causa estructural |
| **4H (`1aa1486`)** | El cleanup de `react-remove-scroll` no restauraba `pointer-events` del body al cerrar | Wrapper de `onOpenChange` en el componente `Sheet` con limpieza diferida 400ms | **No se ejecutaba** — error de diagnóstico, ver abajo |
| **4H-2 (`c93d8e8`)** | — | `useEffect` que observa el prop `open` directamente en lugar de `onOpenChange` | **Confirmado funcionando en producción** |
---
## Causa raíz final (4H-2)
`onOpenChange` de Radix `Dialog.Root` (base del `Sheet` de shadcn/ui) es un canal **unidireccional**: Radix lo invoca cuando decide cerrar por iniciativa propia (Escape, click en el overlay, botón X). Pero `ProfileSheet.handleSave()` cierra el Sheet llamando directamente `onOpenChange(false)`, que en realidad es `setProfileOpen(false)` del componente padre (`AppHeader`) — un cambio de estado controlado desde **afuera** de Radix. En ese flujo, Radix nunca dispara su propio callback `onOpenChange`.
El primer intento de fix (4H) interceptaba ese callback, por lo que nunca se ejecutaba en el caso real que estaba pasando en producción. El fix correcto (4H-2) observa el prop `open` en sí mismo vía `useEffect`, lo cual cubre ambos canales de cierre sin importar cuál lo disparó.
```tsx
function Sheet({ open, ...props }: React.ComponentPropsWithoutRef<typeof SheetPrimitive.Root>) {
React.useEffect(() => {
if (open) return
const timeoutId = window.setTimeout(() => {
const stillHasOpenDialog = document.querySelector('[role="dialog"][data-state="open"]')
if (!stillHasOpenDialog && document.body.style.pointerEvents === 'none') {
document.body.style.pointerEvents = ''
}
}, 400)
return () => window.clearTimeout(timeoutId)
}, [open])
return <SheetPrimitive.Root open={open} {...props} />
}
```
El fix vive en el primitivo `Sheet` compartido (`src/components/ui/sheet.tsx`), no en `ProfileSheet` — protege a cualquier Sheet de la aplicación, presente o futuro.
---
## Observación metodológica
Las primeras ~10 iteraciones (4B a 4G) se basaron en **lectura de código fuente y razonamiento sobre el comportamiento esperado** de Supabase Auth y Radix UI: diagnósticos plausibles, bien fundamentados, pero formulados sobre supuestos del comportamiento del sistema en producción sin observar directamente ese estado.
El punto de quiebre fue cuando el usuario compartió el **HTML real renderizado de la pantalla congelada**. Ese único dato — `<body style="pointer-events: none;">` — redujo el espacio de hipótesis de inmediato: ya no era necesario razonar sobre locks de Supabase, deadlocks de GoTrueClient, o timing de animaciones; el síntoma estaba literalmente escrito en el atributo `style` del elemento raíz. A partir de ahí, el fix llegó en dos iteraciones (una con un error de diagnóstico menor — el canal de `onOpenChange` equivocado — corregido de inmediato al confirmarse que no funcionaba).
**Lección operativa:** ante un bug de UI que persiste a través de varios fixes basados en teoría, pedir evidencia observable directa (HTML del DOM, captura de Network, captura de consola) antes de seguir iterando sobre hipótesis de código es más eficiente que razonar en capas sobre el comportamiento esperado de librerías de terceros. El código dice lo que *debería* pasar; el DOM en vivo dice lo que *está* pasando.
---
## Archivos modificados (total de la resolución)
- `src/auth/SupabaseAuthService.ts`
- `src/auth/AuthContext.tsx`
- `src/components/ProfileSheet.tsx`
- `src/components/ui/sheet.tsx` ← fix final
- `src/lib/supabase.ts`
- `src/store/library-store.ts`
- `src/store/process-store.ts`
- `src/features/report/useReportData.ts`
**Commits:** `dd361b1`, `496c227`, `3d3ae99`, `602957b`, `3ebfd65`, `1c53299`, `7f1f833`, `a1a8b1c`, `985c655`, `aaf1f79`, `1aa1486`, `c93d8e8`
**Tests:** 528 Vitest verdes en cada commit. Build limpio en cada commit.

188
sprints/sprint-6/BRIEF.md Normal file
View File

@@ -0,0 +1,188 @@
# Sprint 6 — BRIEF
> InQ ROI — Client-readiness polish + análisis de sensibilidad
> Inicio: 2026-06-23
> Foco: primer uso real con cliente externo como norte operativo
---
## Contexto
Sprint 5 cerró con el producto estable, en uso interno y con el reporte de ROI funcional. La pregunta que orientó la apertura de Sprint 6 fue: **¿qué le falta a InQ ROI para correrlo en vivo frente a un cliente y producir un informe de ROI creíble hoy?**
Diagnóstico al cierre de Sprint 5:
- El motor de simulación, el heatmap, la ComparisonTable y los 3 PDFs están listos.
- El consultor puede preparar el proceso con anticipación y correr la simulación en reunión.
- Dos gaps concretos antes de la primera sesión con cliente externo:
1. **Sin logo en el header de los PDFs** — el entregable parece borrador sin marca visual.
2. **Sin análisis de sensibilidad** — el ROI es un número puntual; cualquier cliente sofisticado pregunta "¿y si el volumen cae 20%?" y no hay respuesta dentro de la herramienta.
Sprint 6 cierra esos dos gaps.
---
## Etapas
### Etapa 1 — Logo InQuality en header de PDFs + whitespace-nowrap
**Prioridad:** bloqueante para entregable a cliente externo.
**Logo en PDF header:**
Los assets ya existen en `assets/`. Cuatro PNGs disponibles — usar el horizontal color para fondos claros:
- `MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png` — candidato principal (header blanco)
- `MARCA FINAL 2023 AREA DE SEGURIDAD 1 BCO.png` — alternativa para headers con fondo oscuro (si aplica)
El logo va en el header compartido de los 3 PDFs (Actual, Automatizado, ROI). La función `drawSharedHeader` en `src/lib/export/pdf-sections-shared.ts` es el punto de inserción.
**Implementación:**
Convertir el PNG a base64 en tiempo de ejecución usando `fetch` + `arrayBuffer``btoa`, o pre-convertirlo como asset importado con `?url` de Vite. Insertar con `doc.addImage(base64, 'PNG', x, y, width, height)`. El logo reemplaza o complementa el texto "InQuality" que pueda existir actualmente en el header.
Restricciones:
- Ancho máximo: ~35mm (no puede dominar el header, el título del proceso sigue siendo protagonista)
- Alineación: derecha del header (o izquierda — validar visualmente con el director antes del OK formal)
- Mantener "Powered by InQuality" en el footer — no reemplaza, complementa
**Fold-in — whitespace-nowrap en celda de Eficiencia:**
En `src/features/report/ComparisonTable.tsx`, en el `<span>` del número dentro de `MetricCell` para la columna Eficiencia, agregar `whitespace-nowrap` para que "1.02 USD/min" no se corte en dos líneas.
Una línea de cambio. Sin tests que actualizar.
**Criterios:**
- [ ] Logo visible en el header de los 3 PDFs exportados
- [ ] Tamaño y posición validados visualmente por el director
- [ ] "Powered by InQuality" sigue en el footer de todas las páginas
- [ ] "1.02 USD/min" aparece en una sola línea en ComparisonTable
- [ ] `npm run lint` sin errores
- [ ] `npm run build` limpio
- [ ] Tests Vitest verdes (sin regresiones)
- [ ] Validación visual del director sobre el PDF antes del OK formal
---
### Etapa 2 — Panel de análisis de sensibilidad
**Contexto del problema:**
El ROI que calcula InQ ROI hoy es nominal y puntual: dadas estas condiciones exactas, el retorno es X. Pero las condiciones de un proceso real tienen incertidumbre — el volumen puede variar, la inversión puede dispararse, las estimaciones de tiempo pueden estar erradas. Un consultor que presente un ROI de USD 1.2M sin poder responder "¿y si el volumen cae 30%?" pierde credibilidad frente a cualquier CFO.
**Qué NO hacer:** re-simulación completa (implica cargar el BPMN, recorrer gateways, recalcular recursos — es lento y no necesario). Los costos por instancia del proceso ya están calculados y son estables. Solo cambian los agregados anuales.
**Qué SÍ hacer:** un panel que recalcula únicamente las fórmulas de ROI (`domain/roi.ts`) con inputs modificados por sliders — sin tocar el motor de simulación. El cálculo es instantáneo.
**Variables de sensibilidad (dos sliders):**
1. **Volumen (frecuencia anual):** rango -50% a +100%, paso 10%, default 0% (= valor configurado en el proceso). Afecta: ahorro anual, ROI, payback, trayectoria acumulada.
2. **Inversión en automatización:** rango -50% a +100%, paso 10%, default 0% (= valor configurado). Afecta: ROI, payback, trayectoria acumulada. No afecta el ahorro por ejecución.
**UX — versión mínima:**
- Panel colapsable "Análisis de sensibilidad" debajo de los KPIs de ROI en la pestaña "Comparación + ROI"
- Dos sliders con label, valor actual y delta en porcentaje (ej: "Volumen: 12.000 ejecuciones/año (+20%)")
- Los 4 KPIs de ROI (Ahorro por ejecución, Ahorro anual, Payback, ROI anual) se actualizan en tiempo real al mover los sliders
- Badge o indicador visual que muestra que los valores están en modo "análisis" (no son los valores base)
- Botón "Restablecer" que vuelve a los valores originales del proceso
**UX — versión wow (+criterio de wow):**
Además de la versión mínima, agregar una visualización de rango: para la variable más sensible (volumen), mostrar una banda de incertidumbre en el `CumulativeSavingsChart` existente — la línea actual se convierte en el caso base, y se agrega un área sombreada que muestra el rango [-30%, +30%] en el mismo gráfico. Misma paleta naranja, opacidad reducida para el rango. Esto convierte el gráfico de trayectoria de "certeza" a "escenario con rango", que es la forma en que un consultor financiero presenta proyecciones.
Implementar versión wow si el costo extra es < 40% del tiempo de la versión mínima.
**Implementación técnica:**
- El estado de los sliders vive en un `useState` local en `ReportPage.tsx` o en un componente `SensitivityPanel` nuevo — no persiste (se resetea al recargar, es análisis exploratorio)
- El recálculo de KPIs se hace importando las funciones de `domain/roi.ts` directamente, pasando los inputs modificados
- `CumulativeSavingsChart` recibe los datos recalculados vía props — no requiere cambios internos si ya acepta `roi: RoiResult` como prop
- El panel NO se exporta al PDF en esta etapa (es una feature interactiva del reporte web)
**Restricciones:**
- NO re-simular (no llamar al motor BPMN ni a `simulation.ts`)
- NO persistir en Supabase (es exploración, no configuración)
- NO tocar `domain/roi.ts` (solo consumirlo)
- El valor base del proceso (frecuencia, inversión) NO cambia en base de datos
**Criterios:**
- [ ] Panel visible en la pestaña "Comparación + ROI", colapsado por defecto
- [ ] Slider de volumen: -50% a +100%, los KPIs se actualizan en tiempo real
- [ ] Slider de inversión: -50% a +100%, los KPIs se actualizan en tiempo real
- [ ] Los valores base (sin ajuste de sliders) son idénticos a los KPIs actuales
- [ ] Botón "Restablecer" vuelve los sliders a 0%
- [ ] Indicador visual de "modo análisis" activo
- [ ] (Wow) Banda de rango visible en CumulativeSavingsChart si se implementa
- [ ] Tests unitarios del recálculo de sensibilidad en Vitest
- [ ] `npm run lint` sin errores
- [ ] `npm run build` limpio
- [ ] Validación visual del director antes del OK formal
---
### Etapa 3 — Quick wins y limpieza técnica
**3a — Investigación auth E2E localStorage (TECH_DEBT `[INVESTIGATING]`):**
Contradicción documentada en TECH_DEBT.md: en producción, `ProfileSheet` lee `supabase.auth.token`; en E2E, `auth-setup.ts` escribe `sb-${projectRef}-auth-token` y los smoke tests pasan. Determinar definitivamente qué clave usa el cliente Supabase de la app en producción comparando el localStorage real vs el `auth-state.json`.
Acción concreta: agregar un spec E2E que haga una query autenticada a Supabase y afirme que devuelve el `email` real del usuario de test (no vacío). Si el test pasa, el auth E2E funciona realmente. Si falla, el bug está confirmado.
**3b — Cleanup `setTimeout(350)` en ProfileSheet:**
`src/components/ProfileSheet.tsx` tiene un `setTimeout(350)` que difiere `updateUser` como mitigación de la race condition con Radix. El fix definitivo vive en `sheet.tsx` (useEffect sobre prop `open`). El setTimeout es redundante pero inofensivo.
Eliminar el setTimeout, verificar que el fix de `sheet.tsx` sea suficiente. Un test manual de guardar perfil confirma que funciona.
**3c — Animación de expansión sección "Automatización" en ActivityPanel:**
La sección usa el truco de `max-height` para la animación de expand/collapse. La aceleración no es lineal en los últimos píxeles (se nota el "freno" al final).
Fix: usar `useRef + scrollHeight` para animar a la altura real del contenido con una transición CSS. Impacto: micro-pulido visual, moderado esfuerzo.
Solo implementar si 3a y 3b no consumen el slot completo de la etapa.
**Criterios:**
- [ ] Spec E2E de auth: verde si el auth funciona, o diagnóstico definitivo + fix si no funciona
- [ ] `setTimeout(350)` eliminado de ProfileSheet, funcionalidad verificada manualmente
- [ ] TECH_DEBT.md actualizado: marcar RESOLVED los ítems cerrados
- [ ] (Opcional) Animación de expansión más fluida en ActivityPanel
- [ ] `npm run test` → mantener o superar 542 tests verdes
- [ ] Validación del director sobre el estado de la documentación
---
## Distribución del sprint
| Área | Etapas | % estimado |
|---|---|---|
| PDF (logo) | 1 parcial | ~15% |
| Web app features (sensibilidad) | 2 | ~65% |
| Infraestructura / deuda técnica | 3 | ~20% |
Dentro del rango de rebalanceo comprometido (60-70% web, 15-25% PDF, 10-15% infra).
---
## Qué NO entra en Sprint 6
- Historial de simulaciones / múltiples simulaciones por proceso
- Modelo B (dos BPMNs AS-IS / TO-BE)
- Export Excel
- Modo offline / PWA
- i18n
- Compartir procesos con usuarios externos (`setShared()`)
- Keycloak / migración de auth
- Análisis de sensibilidad en PDF (es feature interactiva, PDF es estático)
---
## Definition of Done del sprint
- [ ] 3 etapas con OK formal del director (validación visual de Etapas 1 y 2; validación documental de Etapa 3)
- [ ] `npm run build` limpio
- [ ] `npm run lint` sin warnings nuevos
- [ ] Tests Vitest: mantener o superar 542
- [ ] OBSERVATIONS.md con hallazgos del sprint
- [ ] STRATEGIC_DIRECTION.md actualizado al cerrar el sprint

View File

@@ -0,0 +1,156 @@
# Etapa 1 — Logo InQuality en header de PDFs + whitespace-nowrap
**Sprint:** 6
**Fecha:** 2026-06-23
**Alcance:** 3 PDFs (Actual, Automatizado, ROI) + un fix de una línea en ComparisonTable.
---
## Contexto
El header compartido de los 3 PDFs (`src/lib/export/pdf-sections-shared.ts`, función `drawSharedHeader`) hoy dibuja el texto "InQ ROI" en naranja bold como identidad de marca. El logo PNG de InQuality ya existe en `assets/`. El objetivo es reemplazar el texto por el logo, manteniendo el layout del header sin cambios estructurales.
---
## Parte A — Logo en header de PDFs
### Assets disponibles
En `assets/` (ruta desde la raíz del proyecto):
- `MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png` — logo horizontal color, sobre fondos claros — **usar este**
- `MARCA FINAL 2023 AREA DE SEGURIDAD 1 BCO.png` — logo horizontal blanco (no aplica, el header es blanco)
### Cómo cargar el PNG en jsPDF
Importar la URL del asset con Vite:
```ts
// En pdf-export.ts (o en un helper de carga)
import logoColorUrl from '../../../assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png?url'
```
Convertir a base64 antes de generar cualquier PDF (operación async, una vez por export):
```ts
async function loadImageBase64(url: string): Promise<string> {
const response = await fetch(url)
const blob = await response.blob()
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
```
Llamar una vez al inicio de cada función de export (`exportPdf`, `exportRoiPdf`) y pasar el resultado como parámetro.
### Cambios en `PdfHeaderOptions`
Agregar un campo opcional:
```ts
export interface PdfHeaderOptions {
pageNumber: number
processName: string
sectionTitle?: string
clientName?: string
simulatedAt: number
logoBase64?: string // ← nuevo, opcional para retrocompatibilidad
}
```
### Cambios en `drawSharedHeader`
Reemplazar el bloque de texto "InQ ROI" por lógica condicional:
```ts
// ANTES:
doc.setFont('helvetica', 'bold')
doc.setFontSize(13)
doc.setTextColor(...PDF_COLORS.inqOrange)
doc.text('InQ ROI', MARGIN, y)
// DESPUÉS:
if (options.logoBase64) {
// Logo PNG: ancho 32mm, alto proporcional (~9mm para este logo horizontal)
// Alinear el baseline del logo con la primera línea de texto: y - 7 (arriba del baseline)
doc.addImage(options.logoBase64, 'PNG', MARGIN, y - 7, 32, 9)
} else {
// Fallback al texto (retrocompatibilidad y tests)
doc.setFont('helvetica', 'bold')
doc.setFontSize(13)
doc.setTextColor(...PDF_COLORS.inqOrange)
doc.text('InQ ROI', MARGIN, y)
}
```
**Nota sobre dimensiones:** el logo horizontal de InQuality tiene una proporción aproximada de 3.5:1 (ancho:alto). A 32mm de ancho → ~9mm de alto. Ajustar si el resultado visual no queda bien — la validación visual del director confirma antes del OK formal.
### Cambios en `pdf-export.ts`
En las dos funciones de export (`exportPdf` y `exportRoiPdf`), agregar la carga del logo al inicio:
```ts
export async function exportPdf(...): Promise<void> {
const logoBase64 = await loadImageBase64(logoColorUrl).catch(() => undefined)
const headerOpts: PdfHeaderOptions = {
// ...opciones existentes...
logoBase64,
}
// resto sin cambios
}
```
El `.catch(() => undefined)` garantiza que si el fetch del asset falla (ej: entorno sin red), el PDF se genera igual con el fallback de texto. No bloquear el export por el logo.
### Importante — NO cambiar
- La estructura del header (posiciones de proceso, cliente, fecha, border-bottom) no cambia.
- El footer ("Powered by InQuality") no cambia.
- `drawSharedFooter` y `applyFootersToAllPages` no se tocan.
- No agregar el logo en páginas 1 de cada PDF que tengan su propio diseño de portada (verificar si `drawScenarioPage1` y `drawRoiPage1` usan o no `drawSharedHeader` — si no lo usan, el logo solo aplica a las páginas que sí lo llaman).
---
## Parte B — whitespace-nowrap en celda de Eficiencia
En `src/features/report/ComparisonTable.tsx`, dentro de `MetricCell`, el `<span>` que muestra el valor numérico de la columna "Eficiencia" se corta en dos líneas ("1.02" / "USD/min") porque la celda es angosta.
Fix: agregar `whitespace-nowrap` al className del `<span>`:
```tsx
// Buscar el span que tiene el valor formateado de la Eficiencia y agregar whitespace-nowrap
<span className="font-mono text-slate-700 text-xs w-14 text-right whitespace-nowrap">
{format(value)}
</span>
```
Una línea de cambio. Sin tests que actualizar.
---
## Criterios de validación
- [ ] Logo visible en el header de todas las páginas de los 3 PDFs (Actual, Automatizado, ROI)
- [ ] Logo en tamaño coherente con el header — no dominante, no microscópico
- [ ] Alineación correcta: logo a la izquierda, fecha y cliente a la derecha
- [ ] El footer "Powered by InQuality" sigue presente en todas las páginas
- [ ] Si el asset no carga (fetch falla), el PDF se genera igual con texto "InQ ROI" como fallback
- [ ] "1.02 USD/min" aparece en una sola línea en ComparisonTable
- [ ] `npm run lint` sin errores
- [ ] `npm run build` limpio
- [ ] `npm run test` → 542 tests verdes (sin regresiones — los tests de PDF existentes usan mocks y no verifican el logo)
- [ ] **Validación visual del director sobre el PDF exportado antes del OK formal** — esta es la validación principal de la etapa
---
## Archivos a modificar
1. `src/lib/export/pdf-sections-shared.ts``PdfHeaderOptions` + `drawSharedHeader`
2. `src/lib/export/pdf-export.ts` — carga del logo + pasar `logoBase64` a `headerOpts`
3. `src/features/report/ComparisonTable.tsx``whitespace-nowrap` en `MetricCell`
No modificar ningún otro archivo. En particular, no tocar los tests de PDF existentes — el fallback de texto garantiza que sigan pasando sin cambios.

View File

@@ -0,0 +1,241 @@
# Etapa 2 — Panel de análisis de sensibilidad
**Sprint:** 6
**Fecha:** 2026-06-24
**Alcance:** nuevo componente `SensitivityPanel` + dos sliders + recálculo de KPIs en tiempo real + versión wow con doble línea en `CumulativeSavingsChart`.
---
## Contexto
El ROI que calcula InQ ROI hoy es nominal y puntual: dadas estas condiciones exactas, el retorno es X. Un CFO o interlocutor financiero va a preguntar en la reunión: "¿y si el volumen cae 20%?" o "¿y si la implementación sale más cara?". Sin poder responder eso en vivo, el informe pierde credibilidad.
El panel de sensibilidad responde esas preguntas sin re-simular. Los costos por ejecución ya están calculados y son estables. Solo cambian los agregados anuales. El recálculo es una llamada a `calculateRoi()` con inputs modificados — instantáneo.
**La restricción más importante:** NO re-simular. No tocar `simulation.ts`, no llamar al motor BPMN. Solo `domain/roi.ts`.
---
## Arquitectura — qué cambia y dónde
### Estado nuevo en `ReportPage.tsx`
Agregar al estado local:
```tsx
const [volumeAdjust, setVolumeAdjust] = useState(0) // entero: -50 a +100
const [investmentAdjust, setInvestmentAdjust] = useState(0) // entero: -50 a +100
```
Agregar un `useMemo` para el ROI ajustado, justo después del `roi` existente:
```tsx
// ROI ajustado por sliders de sensibilidad — recalcula solo los agregados anuales,
// sin tocar costPerExecutionActual ni costPerExecutionAutomated (son estables).
const adjustedRoi = useMemo(() => {
if (!roi || !simulation?.result || !simulation?.resultAutomated || !process) return null
if (volumeAdjust === 0 && investmentAdjust === 0) return roi // sin cambios → mismo objeto
return calculateRoi({
costPerExecutionActual: simulation.result.totalCost,
costPerExecutionAutomated: simulation.resultAutomated.totalCost,
annualFrequency: process.annualFrequency * (1 + volumeAdjust / 100),
analysisHorizonYears: process.analysisHorizonYears,
automationInvestment: process.automationInvestment * (1 + investmentAdjust / 100),
})
}, [roi, simulation, process, volumeAdjust, investmentAdjust])
```
### Modificaciones en el tab "ROI" de `ReportPage.tsx`
**Orden del layout** (dentro del bloque `hasAutomatedResult && roi`):
1. `RoiKpiBar` — recibe `adjustedRoi ?? roi` en lugar de `roi`
2. `SensitivityPanel` — nuevo, debajo de los KPIs, colapsado por defecto
3. `ComparisonTable` — sin cambio (per-ejecución, no varía con los sliders)
4. Gráficos: `CumulativeSavingsChart` recibe `adjustedRoi` (y `roi` como base para la versión wow)
Cambios concretos en JSX:
```tsx
{/* KPIs de ROI — actualizados por sliders */}
<RoiKpiBar
roi={adjustedRoi ?? roi}
currency={process.currency}
horizonYears={process.analysisHorizonYears}
/>
{/* Panel de sensibilidad — colapsado por defecto */}
<SensitivityPanel
process={process}
volumeAdjust={volumeAdjust}
investmentAdjust={investmentAdjust}
onVolumeChange={setVolumeAdjust}
onInvestmentChange={setInvestmentAdjust}
onReset={() => { setVolumeAdjust(0); setInvestmentAdjust(0) }}
/>
{/* Tabla de comparación — recibe roi base (per-ejecución, no varía) */}
<div className="mb-6">
<h2 ...>Comparación por actividad</h2>
<ComparisonTable ... />
</div>
{/* Gráficos */}
<CumulativeSavingsChart
roi={adjustedRoi ?? roi}
roiBase={volumeAdjust !== 0 || investmentAdjust !== 0 ? roi : undefined}
horizonYears={process.analysisHorizonYears}
currency={process.currency}
investment={process.automationInvestment * (1 + investmentAdjust / 100)}
/>
```
---
## Componente nuevo: `SensitivityPanel`
**Archivo:** `src/features/report/SensitivityPanel.tsx`
### Props
```tsx
interface SensitivityPanelProps {
process: {
annualFrequency: number
automationInvestment: number
currency: string
}
volumeAdjust: number // entero -50 a +100
investmentAdjust: number // entero -50 a +100
onVolumeChange: (v: number) => void
onInvestmentChange: (v: number) => void
onReset: () => void
}
```
### Comportamiento
- Colapsado por defecto. El header siempre visible, con un chevron toggle.
- Cuando está expandido: dos sliders con label y valor actual.
- Cuando algún slider ≠ 0: mostrar badge "Análisis activo" en naranja junto al título.
- Botón "Restablecer" visible solo cuando algún slider ≠ 0.
### Slider de volumen
- Label: **"Volumen anual"**
- Rango: -50 a +100, paso 10
- Valor mostrado: si ≠ 0, mostrar el valor ajustado con delta: `"12.000 ej/año (+20%)"`. Si = 0, mostrar `"12.000 ej/año (valor base)"`.
- El valor ajustado = `process.annualFrequency * (1 + volumeAdjust / 100)`, formateado con separadores de miles.
### Slider de inversión
- Label: **"Inversión en automatización"**
- Rango: -50 a +100, paso 10
- Valor mostrado: si ≠ 0, mostrar `"USD 80.000 (+10%)"`. Si = 0, `"USD 80.000 (valor base)"`.
- El valor ajustado = `process.automationInvestment * (1 + investmentAdjust / 100)`, formateado con `formatCurrency`.
### Estructura visual del panel
```
┌─────────────────────────────────────────────────┐
│ 📊 Análisis de sensibilidad [●Análisis activo] │ ▾ │
├─────────────────────────────────────────────────┤
│ │
│ Volumen anual │
│ 12.000 ej/año (+20%) │
│ ────────────●──────────────── slider │
│ -50% 0% +20% +100% │
│ │
│ Inversión en automatización │
│ USD 80.000 (valor base) │
│ ──────●──────────────────── slider │
│ -50% 0% +100% │
│ │
│ [Restablecer] │
└─────────────────────────────────────────────────┘
```
### Estilos
- Contenedor: `bg-amber-50 border border-amber-200 rounded-xl p-4 mb-6`
- Badge "Análisis activo": pastilla pequeña en naranja InQ (`bg-orange-100 text-orange-700 text-xs font-medium px-2 py-0.5 rounded-full`)
- Slider nativo HTML: `<input type="range">` con `accent-color: #F59845`
- Botón Restablecer: variante `outline`, tamaño `sm`, solo visible cuando `volumeAdjust !== 0 || investmentAdjust !== 0`
---
## Versión wow — doble línea en `CumulativeSavingsChart`
**Condición:** implementar si el costo extra está dentro del 40% del tiempo de la versión mínima.
### Cambios en `CumulativeSavingsChart.tsx`
Agregar prop opcional:
```tsx
interface CumulativeSavingsChartProps {
roi: RoiResult // caso ajustado (o base si no hay ajuste)
horizonYears: number
currency: string
investment: number // inversión ajustada (usada para cálculo de valores del eje Y)
roiBase?: RoiResult // caso base original — cuando presente, mostrar como línea secundaria
}
```
Cuando `roiBase` está presente (sliders activos):
1. **Línea secundaria** (caso base): `roiBase.annualSavings * y - investmentOriginal` — dibujada como línea delgada (width: 1.5) en `#94a3b8` (slate-400), tipo `dashed`, sin punto de payback.
2. **Área entre las dos líneas**: series de tipo `line` con `areaStyle`, naranja InQ a opacidad 0.08, entre la línea base y la ajustada.
3. **Línea principal** (caso ajustado): igual que hoy — naranja InQ, width 2.5, con el marcador de payback si aplica.
El eje Y debe escalar para acomodar ambas líneas (usar `Math.min` y `Math.max` de todos los valores).
**Si el costo supera el 40%:** entregar solo la versión mínima (SensitivityPanel funcional, RoiKpiBar actualizado, sin doble línea). Reportar la estimación de tiempo real al director.
---
## Criterios de validación
- [ ] Panel visible en tab "Comparación + ROI", colapsado por defecto
- [ ] Al expandir, se ven dos sliders con labels y valores actualizados en tiempo real
- [ ] Mover slider de volumen actualiza los 4 KPIs de ROI en tiempo real (sin delay perceptible)
- [ ] Mover slider de inversión actualiza payback y ROI% en tiempo real
- [ ] Con ambos sliders en 0, los KPIs son idénticos a los valores originales
- [ ] Badge "Análisis activo" aparece cuando algún slider ≠ 0, desaparece al restablecer
- [ ] Botón "Restablecer" vuelve ambos sliders a 0 y los KPIs al valor base
- [ ] `ComparisonTable` no cambia al mover los sliders (per-ejecución, correcto)
- [ ] (Wow) Cuando sliders activos: CumulativeSavingsChart muestra línea base en gris + área sombreada + línea ajustada en naranja
- [ ] Tests unitarios del recálculo: al menos 6 casos (volumen +20%, -30%; inversión +50%, -50%; combinado; reset a 0)
- [ ] `npm run lint` sin errores
- [ ] `npm run build` limpio
- [ ] `npm run test` → 542+ tests verdes
- [ ] **Validación visual del director sobre el panel en web antes del OK formal**
---
## Archivos a crear o modificar
**Crear:**
- `src/features/report/SensitivityPanel.tsx`
- `tests/features/report/sensitivity.test.ts`
**Modificar:**
- `src/features/report/ReportPage.tsx` — estado + adjustedRoi + layout en tab ROI
- `src/features/report/CumulativeSavingsChart.tsx` — prop `roiBase` opcional (versión wow)
**No modificar:**
- `domain/roi.ts` — solo consumir, no tocar
- `domain/simulation.ts` — no re-simular
- Supabase / persistencia — este panel es efímero, vive solo en memoria
- `ComparisonTable.tsx` — no recibe el roi ajustado, opera sobre datos per-ejecución
- Tests existentes de PDF — no se tocan
---
## Notas para el agente
1. El `adjustedRoi` es `null` hasta que `roi` y `process` están cargados — manejar ese caso con el mismo guard que usa `roi` hoy (`!hasAutomatedResult || !roi`).
2. Cuando `volumeAdjust === 0 && investmentAdjust === 0`, `adjustedRoi === roi` (mismo objeto por referencia, gracias al early return en el useMemo) — no hay render adicional innecesario.
3. El slider HTML nativo es suficiente — no usar ninguna librería de sliders. `accent-color` en línea o via Tailwind `accent-orange-400`.
4. El `investment` que pasa a `CumulativeSavingsChart` debe ser el ajustado: `process.automationInvestment * (1 + investmentAdjust / 100)` — de lo contrario el gráfico no arrancará en el punto correcto del eje Y.
5. La versión wow agrega props opcionales a `CumulativeSavingsChart` — la firma existente debe seguir siendo válida sin cambios (retrocompatibilidad con los tests existentes que mockean el componente).

View File

@@ -0,0 +1,242 @@
# Etapa 3 — Limpieza técnica y diagnóstico de auth
**Sprint:** 6
**Fecha:** 2026-06-24
**Alcance:** fix confirmado de localStorage key en ProfileSheet, eliminación de setTimeout(350), spec E2E de auth, actualización de TECH_DEBT.md.
---
## Contexto
Esta etapa cierra deuda técnica documentada y resuelve una contradicción activa en el manejo de auth que podría ser un bug real de persistencia de perfil.
---
## Parte A — Bug potencial: localStorage key en ProfileSheet
### El problema
`ProfileSheet.getAccessToken()` lee de la clave `'supabase.auth.token'`:
```ts
// ProfileSheet.tsx línea 14-23
function getAccessToken(): string | null {
try {
const raw = localStorage.getItem('supabase.auth.token') // ← clave potencialmente incorrecta
...
}
}
```
Pero `auth-setup.ts` documenta y confirma empíricamente que `@supabase/supabase-js v2`'s `createClient()` escribe la sesión bajo `sb-${projectRef}-auth-token`, no bajo `supabase.auth.token`. El `projectRef` se deriva de la URL del proyecto.
Si en producción la sesión está bajo `sb-${projectRef}-auth-token`, entonces `localStorage.getItem('supabase.auth.token')` retorna `null``getAccessToken()` retorna `null``if (!token) return` en línea 67 silencia el error → **el persist a Supabase de nombre y empresa nunca se ejecuta**. El usuario ve el cambio en UI (vía `updateUser()` en memoria) pero no se persiste.
### Diagnóstico requerido
Antes de modificar código, confirmar el estado real con un spec o con `console.log` en dev:
**Opción A (spec):** agregar un spec E2E `tests/e2e/auth-localStorage-key.spec.ts` que:
1. Navega con `storageState` de auth
2. Evalúa `document.cookie` o `localStorage` en el contexto del browser
3. Afirma qué clave de auth existe realmente (`supabase.auth.token` vs `sb-${projectRef}-auth-token`)
4. Afirma que la clave correcta contiene un `access_token` válido (string no vacío)
**Fix según diagnóstico:**
Si la clave real es `sb-${projectRef}-auth-token` (probable):
```ts
// Reemplazar getAccessToken() completa:
function getAccessToken(): string | null {
try {
// supabase-js v2 calcula la clave como sb-{projectRef}-auth-token
// donde projectRef = hostname.split('.')[0] de la URL del proyecto.
const projectRef = new URL(import.meta.env.VITE_SUPABASE_URL as string).hostname.split('.')[0]
const storageKey = `sb-${projectRef}-auth-token`
const raw = localStorage.getItem(storageKey)
if (!raw) return null
const parsed = JSON.parse(raw) as { access_token?: string }
return parsed.access_token ?? null
} catch {
return null
}
}
```
Si en producción ambas claves coexisten (posible si hay versiones mixtas de @supabase/auth-js), leer con fallback:
```ts
const raw = localStorage.getItem(storageKey) ?? localStorage.getItem('supabase.auth.token')
```
**Actualizar el comentario** en `ProfileSheet.tsx` para reflejar la clave real y el diagnóstico confirmado. El comentario actual dice "STORAGE_KEY = 'supabase.auth.token'" — eso era la documentación de auth-js v1/v2-old, no de supabase-js v2.
### Criterios de Parte A
- [ ] Spec E2E confirma qué clave existe en localStorage en producción bajo auth real
- [ ] `getAccessToken()` lee la clave correcta
- [ ] Comentario en ProfileSheet.tsx refleja la clave real y la razón
- [ ] El save de perfil (nombre, empresa) persiste en Supabase — verificar manualmente en Studio o via spec
---
## Parte B — Eliminar setTimeout(350) de ProfileSheet
### El problema
`ProfileSheet.handleSave()` difiere `updateUser()` 350ms:
```ts
// ProfileSheet.tsx líneas 57-60
onOpenChange(false)
setTimeout(() => {
updateUser({ name: savedName, company: savedCompany })
}, 350)
```
El comentario dice que es para evitar que `@radix-ui/react-remove-scroll` deje `pointer-events:none` permanente en el body. Pero `src/components/ui/sheet.tsx` ya tiene el fix definitivo (useEffect sobre prop `open` que restaura pointer-events defensivamente). El setTimeout es redundante.
### Fix
1. Reemplazar el bloque con llamada directa:
```ts
onOpenChange(false)
updateUser({ name: savedName, company: savedCompany })
```
2. Verificar manualmente que después de guardar el perfil, la UI responde (no hay pointer-events:none atascado en body). Si el fix de sheet.tsx es suficiente, el comportamiento es idéntico. Si reaparece el bug, restaurar el setTimeout y documentar que el fix de sheet.tsx no es suficiente solo.
3. Actualizar el comentario para explicar que el fix vive en sheet.tsx.
### Criterios de Parte B
- [ ] setTimeout(350) eliminado de ProfileSheet.tsx
- [ ] Verificación manual: guardar perfil → UI responde correctamente, sin pointer-events bloqueados
- [ ] Comentario actualizado explicando que el fix está en sheet.tsx
---
## Parte C — Spec E2E de validación de auth real
Independientemente de la resolución de Parte A, agregar un spec que valide que el auth E2E funciona realmente (no solo que el storageState se carga, sino que Supabase devuelve datos reales del usuario).
**Archivo:** `tests/e2e/auth-real.spec.ts`
```ts
import { test, expect } from '@playwright/test'
test.use({ storageState: 'tests/e2e/setup/auth-state.json' })
test('auth E2E: Supabase devuelve datos reales del usuario autenticado', async ({ page }) => {
// Navegar a la app autenticada
await page.goto('http://localhost:4173/')
// Esperar a que AppHeader muestre el avatar/nombre (confirma que AuthContext cargó la sesión)
await expect(page.getByTestId('app-header-user-avatar')).toBeVisible({ timeout: 10000 })
// Verificar que el email del usuario de test aparece en el dropdown de perfil
// (o bien hacer una query directa a Supabase REST para confirmar que el token es válido)
const email = process.env.TEST_USER_EMAIL
if (email) {
await page.getByTestId('app-header-user-avatar').click()
await expect(page.getByText(email)).toBeVisible()
}
})
```
Adaptar según los `data-testid` reales del AppHeader (consultarlos antes de escribir el spec).
Si `TEST_USER_EMAIL` no está en `.env.test`, el spec hace skip con mensaje descriptivo (mismo patrón de `export-pdf.spec.ts`).
### Criterios de Parte C
- [ ] Spec en `tests/e2e/auth-real.spec.ts` que pasa cuando auth funciona
- [ ] Spec hace `test.skip()` descriptivo si `TEST_USER_EMAIL` no está configurado
- [ ] Si el spec falla, el diagnóstico es definitivo: el auth E2E no funciona realmente → fix en auth-setup.ts o en AuthContext
---
## Parte D — Actualización de TECH_DEBT.md
Al terminar A, B y C, actualizar TECH_DEBT.md:
1. Marcar `[INVESTIGATING] Clave de localStorage del auth E2E vs producción` como `[RESOLVED]` con el resultado real del diagnóstico.
2. Marcar `[BACKLOG] setTimeout(350) en ProfileSheet` como `[RESOLVED]`.
3. Agregar nuevo ítem:
```markdown
### [BACKLOG] CumulativeSavingsChart — derivación de inversión base desde cumulativeSavingsHorizon
**Estado actual:** cuando `roiBase` está presente (modo sensibilidad activo), el componente
recupera la inversión original algebraicamente: `investment_base = roiBase.annualSavings * horizonYears - roiBase.cumulativeSavingsHorizon`.
Es correcto mientras `horizonYears` del chart coincida con el usado en `calculateRoi` (hoy siempre es el mismo).
**Riesgo:** si en el futuro el chart recibe un horizonte diferente al del cálculo de ROI, el valor
de referencia del eje Y para la línea base será incorrecto, sin error visible.
**Solución propuesta:** agregar prop `investmentBase?: number` a `CumulativeSavingsChartProps`
y pasar `process.automationInvestment` directamente desde `ReportPage`.
**Costo:** bajo. **Origen:** Sprint 6 Etapa 2 — detectado en auditoría post-implementación.
```
---
## Parte E — Opcional: animación de expansión en ActivityPanel
Solo implementar si las partes A-D no consumen el slot completo.
La sección de automatización en `ActivityPanel` usa `max-height` para expandir/colapsar. La aceleración no es lineal en los últimos píxeles (se nota el "freno" al final).
Fix: usar `useRef + scrollHeight` para animar a la altura real del contenido:
```tsx
const contentRef = useRef<HTMLDivElement>(null)
// Al expandir:
contentRef.current.style.maxHeight = contentRef.current.scrollHeight + 'px'
// Al colapsar:
contentRef.current.style.maxHeight = '0'
```
Con transición CSS `transition-[max-height] duration-300 ease-in-out`.
Verificar visualmente antes de commitear.
---
## Orden de ejecución recomendado
1. Parte A (diagnóstico y fix de localStorage key) — puede revelar un bug real, prioridad más alta
2. Parte B (setTimeout cleanup) — depende de confirmar que sheet.tsx fix es suficiente
3. Parte C (spec E2E) — complementa Parte A
4. Parte D (TECH_DEBT.md) — al final, cuando A-C estén resueltos
5. Parte E (animación) — solo si hay tiempo
---
## Criterios de validación del conjunto
- [ ] `getAccessToken()` en ProfileSheet.tsx usa la clave correcta de localStorage
- [ ] Save de perfil persiste en Supabase (nombre y empresa visibles al recargar)
- [ ] setTimeout(350) eliminado, UI funciona correctamente después de guardar perfil
- [ ] Spec E2E de auth pasa o hace skip descriptivo
- [ ] TECH_DEBT.md actualizado: ítems resueltos marcados, nueva nota de CumulativeSavingsChart
- [ ] `npm run lint` sin errores
- [ ] `npm run build` limpio
- [ ] `npm run test` → 552+ tests verdes
- [ ] Validación del director sobre el estado de la documentación antes del OK formal
---
## Archivos a modificar
- `src/components/ProfileSheet.tsx` — clave localStorage + eliminar setTimeout
- `tests/e2e/auth-localStorage-key.spec.ts` — spec de diagnóstico (nuevo)
- `tests/e2e/auth-real.spec.ts` — spec de validación E2E (nuevo)
- `TECH_DEBT.md` — marcar resueltos + agregar nota CumulativeSavingsChart
**Opcional:**
- `src/features/workspace/ActivityPanel.tsx` — animación scrollHeight
**No modificar:**
- `tests/e2e/setup/auth-setup.ts` — ya documentado correctamente
- Ningún otro archivo fuera de esta lista sin consulta previa

View File

@@ -0,0 +1,345 @@
# Referencia: Google OAuth con Supabase para InQ Sizing
> Extraído de la implementación de InQ ROI (Stack: Vite + React 18 + TS + Supabase cloud).
> InQ Sizing usa Supabase **self-hosted en Dokploy** — las variables de GoTrue son equivalentes al dashboard cloud.
---
## 1. Configuración de GoTrue para Google OAuth (self-hosted)
InQ ROI usa Supabase cloud, donde esto se configura en el dashboard.
Para Supabase self-hosted en Dokploy, las variables de entorno equivalentes son:
```env
# En el servicio GoTrue (supabase-auth)
GOTRUE_EXTERNAL_GOOGLE_ENABLED=true
GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=<client_id_de_google_cloud_console>
GOTRUE_EXTERNAL_GOOGLE_SECRET=<client_secret>
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=https://<tu-dominio-supabase>/auth/v1/callback
# URLs que deben coincidir entre GoTrue y la consola de Google OAuth
API_EXTERNAL_URL=https://<tu-dominio-supabase>
SITE_URL=https://<tu-app>
ADDITIONAL_REDIRECT_URLS=https://<tu-app>,http://localhost:5173
```
En Google Cloud Console: crear credenciales OAuth 2.0 tipo "Aplicación web",
agregar `https://<tu-dominio-supabase>/auth/v1/callback` como "URI de redirección autorizado".
---
## 2. Tabla `public.users` — estructura de InQ ROI
```sql
-- supabase/migrations/001_create_users.sql
CREATE TABLE IF NOT EXISTS public.users (
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
name text,
avatar_url text,
platform_role text NOT NULL DEFAULT 'guest'
CHECK (platform_role IN ('platform_admin', 'member', 'guest')),
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- Cualquier usuario autenticado puede leer la tabla users
CREATE POLICY "read_users" ON public.users FOR SELECT USING (auth.uid() IS NOT NULL);
-- Solo puede actualizar su propia fila
CREATE POLICY "update_own_user" ON public.users FOR UPDATE USING (id = auth.uid());
-- El propio usuario puede insertar su fila (habilita el upsert application-side)
CREATE POLICY "insert_users" ON public.users FOR INSERT WITH CHECK (id = auth.uid());
```
**Nota clave:** la política `insert_users` permite que el propio usuario inserte su fila.
Esto es lo que habilita el upsert desde la aplicación sin necesidad de un trigger SQL.
---
## 3. Cómo se crea la fila en `public.users` al primer login
InQ ROI resuelve esto **a nivel de aplicación**, no con un trigger SQL.
### 3a. `upsertUserOnLogin()` en `SupabaseAuthService.ts`
```ts
async upsertUserOnLogin(user: AuthUser, signal?: AbortSignal): Promise<void> {
const builder = supabase.from('users').upsert(
{
id: user.id,
name: user.name,
avatar_url: user.avatarUrl ?? null,
platform_role: user.platformRole, // derivado del email, ver sección 4
},
{ onConflict: 'id', ignoreDuplicates: true } // no sobreescribe si ya existe
)
const { error } = await (signal ? builder.abortSignal(signal) : builder)
if (error) console.error('Error al crear usuario en DB:', error)
}
```
### 3b. Llamada desde `AuthContext.tsx` — FUERA del lock de auth
```tsx
useEffect(() => {
if (!user?.id) return
const userId = user.id
const capturedUser = user
// 6s timeout: cancela si el servidor no responde, libera el processLock
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 6000)
;(async () => {
try {
// Upsert idempotente: crea la fila si no existe (primer login con Google)
await authService.upsertUserOnLogin(capturedUser, controller.signal)
// Leer company y name desde DB (pueden diferir del user_metadata de Google)
const profile = await authService.syncProfileFromDB(userId, controller.signal)
if (profile) {
setUser((prev) =>
prev?.id === userId
? { ...prev, company: profile.company, name: profile.name || prev.name }
: prev
)
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
console.error('Error al sincronizar perfil:', err)
} finally {
clearTimeout(timeoutId)
}
})()
return () => {
controller.abort()
clearTimeout(timeoutId)
}
}, [user?.id]) // dep primitiva: no cambia cuando el usuario edita nombre/empresa
```
---
## 4. Cómo se determina el rol — derivación desde el email
Esta es la decisión arquitectónica central de InQ ROI: **el rol es determinista a partir del dominio del email**, sin asignación manual ni pantalla de "esperando aprobación".
```ts
// En buildAuthUser() — síncrono, sin consultar DB
private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): AuthUser {
const email = supabaseUser.email ?? ''
return {
id: supabaseUser.id,
email,
name: (supabaseUser.user_metadata?.full_name as string | undefined) || email,
avatarUrl: supabaseUser.user_metadata?.avatar_url as string ?? undefined,
platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest',
}
}
```
El upsert del primer login ya graba el rol correcto desde el inicio.
---
## 5. Frontend — `signInWithOAuth`
```ts
// src/auth/SupabaseAuthService.ts
async signInWithGoogle(): Promise<void> {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: window.location.origin,
queryParams: {
prompt: 'select_account', // fuerza el selector aunque haya sesión activa
},
},
})
if (error) throw error
}
```
---
## 6. Manejo del redirect OAuth — evitar loop /login
Cuando Google redirige de vuelta a la app, la URL tiene `#access_token=...` o `?code=...`.
Si el contexto lee localStorage antes de que Supabase procese ese hash, puede redirigir
a `/login` causando un flash o loop. InQ ROI lo previene con esta lógica en `AuthContext`:
```ts
function hasOAuthCallback(): boolean {
const hash = window.location.hash
const search = window.location.search
return (
hash.includes('access_token') ||
hash.includes('refresh_token') ||
search.includes('code=')
)
}
// En useState inicial:
const isOAuthRedirect = hasOAuthCallback()
const [user, setUser] = useState<AuthUser | null>(
isOAuthRedirect ? null : getUserFromStorage() // no leer storage durante el callback
)
const [loading, setLoading] = useState(isOAuthRedirect) // loading=true hasta que onAuthStateChange dispare
```
### Lectura de localStorage sin red (para arranque inmediato)
```ts
function getUserFromStorage(): AuthUser | null {
try {
const key = Object.keys(localStorage).find(
(k) => k.startsWith('sb-') && k.endsWith('-auth-token')
)
if (!key) return null
const stored = JSON.parse(localStorage.getItem(key) ?? 'null')
const u = stored?.user
if (!u?.id) return null
const email = (u.email as string) ?? ''
return {
id: u.id as string,
email,
name: (u.user_metadata?.full_name as string | undefined) || email,
avatarUrl: u.user_metadata?.avatar_url as string | undefined,
platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest',
}
} catch {
return null
}
}
```
**Nota:** La clave en localStorage es `sb-${projectRef}-auth-token` (donde `projectRef` es
el hostname del `SUPABASE_URL` sin dominio). La clave `supabase.auth.token` que documenta
`@supabase/auth-js` en aislamiento **no existe**`createClient()` de `@supabase/supabase-js`
la sobreescribe con la clave prefijada `sb-`. Confirmado por diagnóstico E2E en Sprint 6 Etapa 3.
---
## 7. Restricción crítica: `onAuthStateChange` callback DEBE ser síncrono
El callback de `onAuthStateChange` se ejecuta mientras GoTrueClient mantiene su lock interno activo.
Hacer `await` de cualquier operación Supabase dentro de ese callback causa un deadlock permanente.
```ts
// MAL — deadlock garantizado:
supabase.auth.onAuthStateChange(async (event, session) => {
await supabase.from('profiles').select(...) // bloquea el processLock para siempre
})
// BIEN — síncrono, queries afuera del lock:
supabase.auth.onAuthStateChange((event, session) => {
// Solo datos del token en memoria, sin queries
callback(this.buildAuthUser(session.user))
})
// Las queries a profiles van en useEffect([user?.id]) fuera del lock
```
---
## 8. Configuración del cliente Supabase — processLock
```ts
// src/lib/supabase.ts
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
// processLock usa coordinación solo en memoria (sin navigator.locks).
// Evita deadlocks entre tabs a costa de no sincronizar refresh entre tabs
// (aceptable para producto mayormente single-tab).
lock: processLock,
// 30s en lugar del default de 5s: margen para refresh en redes lentas / cold starts.
lockAcquireTimeout: 30000,
},
})
```
---
## 9. Variables de entorno del frontend
```env
VITE_SUPABASE_URL=https://<tu-dominio-supabase>
VITE_SUPABASE_ANON_KEY=<anon-public-key>
```
No hay configuración especial en el cliente para Google OAuth.
`signInWithOAuth` funciona con el cliente estándar una vez que GoTrue tiene habilitado el proveedor.
---
## 10. La pregunta crítica para InQ Sizing: el problema del rol vacío
InQ ROI tiene 2 roles y la regla es trivial (dominio del email). InQ Sizing tiene 4 roles
(`admin|comercial|cliente_coordinador|cliente_gerente`) donde el rol define visibilidad
de datos via RLS — **no hay forma de derivarlo del email automáticamente**.
### Opción A: Trigger SQL en `auth.users` (recomendada cuando rol vacío rompe RLS)
```sql
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
INSERT INTO public.profiles (id, organization_id, full_name, role)
VALUES (
NEW.id,
NULL, -- admin asigna org después
NEW.raw_user_meta_data->>'full_name',
'pendiente' -- rol especial hasta asignación manual
)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
```
La fila existe siempre desde el primer login, incluso antes de que la app se cargue.
El admin asigna `role` + `organization_id` después. RLS puede permitir que `role = 'pendiente'`
acceda solo a una pantalla de "acceso pendiente" en lugar de romper totalmente.
### Opción B: Upsert application-side (igual que InQ ROI) + rol default `'pendiente'`
Misma estructura que InQ ROI pero con `role: 'pendiente'` en el upsert.
Riesgo: ventana de tiempo entre el login y el `useEffect` donde RLS puede romper
las primeras queries (devuelven vacío o error silencioso).
### Comparativa
| Aspecto | InQ ROI | InQ Sizing (a decidir) |
|---|---|---|
| Roles | 2, derivados de email | 4, no derivables de email |
| Creación de fila en profiles | Upsert app-side en `useEffect` | Recomiendo trigger SQL en `auth.users` |
| Rol en primer login | Inmediato y determinista | Necesita flujo "pendiente" + asignación manual por admin |
| RLS sin fila existente | Queries devuelven vacío (aceptable) | Probablemente rompe — evaluar antes de elegir opción |
---
## 11. Clave de localStorage — diagnóstico
Si necesitan leer el token directamente (como InQ ROI en `ProfileSheet.getAccessToken()`
para evitar contención con processLock al guardar perfil):
```ts
function getAccessToken(): string | null {
try {
// @supabase/supabase-js createClient() calcula la clave como sb-{projectRef}-auth-token
// donde projectRef = hostname de SUPABASE_URL sin dominio.
// La clave 'supabase.auth.token' de @supabase/auth-js en aislamiento NO existe.
const projectRef = new URL(SUPABASE_URL).hostname.split('.')[0]
const storageKey = `sb-${projectRef}-auth-token`
const raw = localStorage.getItem(storageKey)
if (!raw) return null
const parsed = JSON.parse(raw) as { access_token?: string }
return parsed.access_token ?? null
} catch {
return null
}
}
```

555
sprints/sprint-7/BRIEF.md Normal file
View File

@@ -0,0 +1,555 @@
# Sprint 7 — BRIEF
> InQ ROI — Multi-usuario y acceso de clientes
> Inicio: post-cierre Sprint 6
> Foco: primer acceso de equipo cliente con roles diferenciados e InQuality como administrador de plataforma
---
## Contexto
Sprint 6 cerró el producto listo para uso interno y para primera presentación a clientes con datos reales. Sprint 7 abre el producto a usuarios del lado del cliente: pueden editar parámetros de sus procesos, simular, ver reportes y exportar PDFs — con visibilidad estrictamente acotada a su organización. InQuality mantiene el control total de invitaciones, roles y gestión de usuarios.
El esquema de autenticación replica el patrón de **InQ Sizing** (documentado en `cowork/InQ ROI - Simulador de procesos/autenticacion-y-usuarios.md`) que ya resolvió estos problemas. Este sprint adapta ese esquema al stack de InQ ROI (TanStack Router en lugar de React Router, `public.users` en lugar de `profiles`, etc.).
---
## Modelo de roles (definición final)
| Rol | Quién | Acceso |
|---|---|---|
| `platform_admin` | InQuality | Todo: crear procesos, gestionar orgs, invitar/suspender/eliminar usuarios |
| `member` | InQuality | Crear y gestionar procesos, asignar a orgs — sin gestión de usuarios |
| `client_editor` | Cliente, operativo | Workspace completo de los procesos de su org (editar costos/tiempos/recursos, simular, reporte, PDF) — sin importar BPMNs nuevos |
| `client_viewer` | Cliente, decisor | Solo reporte + sensibilidad + PDF de los procesos de su org — sin edición |
> **Nota sobre roles existentes:** `platform_role = 'guest'` existe en la DB pero queda deprecado. Los usuarios `@inquality.com.py` activos son `platform_admin` o `member`. El rol `guest` solo se mantiene en el check constraint por compatibilidad; ningún usuario nuevo recibirá ese rol.
---
## Estado actual del schema relevante
Migraciones ya aplicadas (001013):
- `public.users`: `id`, `name`, `avatar_url`, `platform_role` ('platform_admin' | 'member' | 'guest'), `company`, `created_at`
- `public.processes`: tiene campo `client` (text) para nombre del cliente — se mantiene para display (PDF, header); se agrega `org_id` FK para control de acceso
- `public.user_groups` + `group_memberships`: agrupaciones internas de InQuality — se conservan sin cambios
- `public.process_access`: access grants por usuario o grupo — se conserva sin cambios
- `public.is_platform_admin()`: función SECURITY DEFINER existente — se mantiene
Problema crítico identificado en auditoría (Sprint 6 Etapa 3): `AuthContext.getUserFromStorage()` y `SupabaseAuthService.buildAuthUser()` derivan `platformRole` del dominio del email en tiempo de ejecución (`@inquality.com.py``platform_admin`, else → `guest`). Para que los roles de cliente funcionen, `syncProfileFromDB` debe leer también `platform_role` desde la DB. Los nuevos roles (`client_editor`, `client_viewer`) con emails no-InQuality quedarían atrapados en `guest` sin este fix.
---
## Decisión de migración: `client` text → `org_id` FK
Estrategia **Opción A** (decidida por el director):
- Script de migración crea una organización por cada valor único de `processes.client`
- Vincula los procesos a esa org via `org_id`
- El campo `client` (text) **se mantiene** para display (PDF, header del proceso) — los dos campos coexisten
- `org_id = null` significa "sin organización asignada" — los procesos existentes sin `client` quedan sin org y solo InQuality los ve
---
## Etapas
### Etapa 1 — Migraciones de base de datos + helpers
**Archivos a crear:** `supabase/migrations/014_create_organizations.sql`, `supabase/migrations/015_client_roles_and_rls.sql`
**014 — Organizations + org_id:**
```sql
-- Tabla organizations
CREATE TABLE public.organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
is_provider boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
-- InQuality como org proveedora
INSERT INTO public.organizations (name, is_provider)
VALUES ('InQuality', true);
-- org_id en users (nullable — usuarios InQuality no necesitan org de cliente)
ALTER TABLE public.users ADD COLUMN IF NOT EXISTS org_id uuid
REFERENCES public.organizations(id) ON DELETE SET NULL;
-- org_id en processes (nullable — procesos sin cliente asignado)
ALTER TABLE public.processes ADD COLUMN IF NOT EXISTS org_id uuid
REFERENCES public.organizations(id) ON DELETE SET NULL;
-- Migración automática: crear orgs desde valores únicos de processes.client
INSERT INTO public.organizations (name, is_provider)
SELECT DISTINCT client, false
FROM public.processes
WHERE client IS NOT NULL AND trim(client) != ''
ON CONFLICT DO NOTHING;
-- Vincular procesos a sus orgs
UPDATE public.processes p
SET org_id = o.id
FROM public.organizations o
WHERE trim(o.name) = trim(p.client) AND o.is_provider = false;
-- Ampliar check constraint de platform_role para incluir roles de cliente
ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_platform_role_check;
ALTER TABLE public.users ADD CONSTRAINT users_platform_role_check
CHECK (platform_role IN ('platform_admin', 'member', 'client_editor', 'client_viewer', 'guest'));
```
**015 — SECURITY DEFINER helpers + RLS actualizada:**
```sql
-- Helper: org del usuario actual
CREATE OR REPLACE FUNCTION public.current_org()
RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER AS $$
SELECT org_id FROM public.users WHERE id = auth.uid()
$$;
-- Helper: ¿es usuario InQuality? (tiene org con is_provider=true)
CREATE OR REPLACE FUNCTION public.is_inquality()
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $$
SELECT EXISTS (
SELECT 1 FROM public.users u
JOIN public.organizations o ON o.id = u.org_id
WHERE u.id = auth.uid() AND o.is_provider = true
)
$$;
-- Helper: rol del usuario actual
CREATE OR REPLACE FUNCTION public.current_platform_role()
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER AS $$
SELECT platform_role FROM public.users WHERE id = auth.uid()
$$;
-- Trigger handle_new_user: auto-crear fila en public.users para Google OAuth @inquality.com.py
-- Usuarios clientes se crean via Edge Function (inviteUserByEmail), no por trigger.
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
provider_org_id uuid;
BEGIN
IF new.email LIKE '%@inquality.com.py' THEN
SELECT id INTO provider_org_id
FROM public.organizations WHERE is_provider = true LIMIT 1;
INSERT INTO public.users (id, name, avatar_url, platform_role, org_id)
VALUES (
new.id,
COALESCE(new.raw_user_meta_data->>'full_name', new.email),
new.raw_user_meta_data->>'avatar_url',
'member', -- rol conservador; platform_admin se asigna manualmente
provider_org_id
)
ON CONFLICT (id) DO NOTHING; -- no sobrescribir si Edge Function ya creó la fila
END IF;
RETURN new;
END;
$$;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user();
-- RLS: organizations
CREATE POLICY "orgs_visible" ON public.organizations
FOR SELECT USING (
public.is_inquality() OR id = public.current_org()
);
CREATE POLICY "orgs_insert" ON public.organizations
FOR INSERT WITH CHECK (
public.is_platform_admin()
);
CREATE POLICY "orgs_update" ON public.organizations
FOR UPDATE USING (public.is_platform_admin());
-- RLS: users — actualizar para que platform_admin vea todos
DROP POLICY IF EXISTS "read_users" ON public.users;
CREATE POLICY "read_own_user" ON public.users
FOR SELECT USING (id = auth.uid());
CREATE POLICY "admin_read_all_users" ON public.users
FOR SELECT USING (public.is_platform_admin());
-- RLS: processes — reemplazar select y update para incluir acceso por org
DROP POLICY IF EXISTS "select_processes" ON public.processes;
CREATE POLICY "select_processes" ON public.processes
FOR SELECT USING (
public.is_platform_admin()
OR owner_id = auth.uid()
OR (public.current_org() IS NOT NULL AND org_id = public.current_org())
OR EXISTS (
SELECT 1 FROM public.process_access
WHERE process_id = processes.id
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR (grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
))
)
)
);
DROP POLICY IF EXISTS "insert_processes" ON public.processes;
CREATE POLICY "insert_processes" ON public.processes
FOR INSERT WITH CHECK (
public.is_platform_admin()
OR public.current_platform_role() = 'member'
);
DROP POLICY IF EXISTS "update_processes" ON public.processes;
CREATE POLICY "update_processes" ON public.processes
FOR UPDATE USING (
public.is_platform_admin()
OR owner_id = auth.uid()
OR (
public.current_platform_role() = 'client_editor'
AND org_id = public.current_org()
)
OR EXISTS (
SELECT 1 FROM public.process_access
WHERE process_id = processes.id AND permission = 'edit'
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR (grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
))
)
)
);
-- RLS: activities, resources, simulations, activity_resource_assignments
-- Reemplazar política amplia por filtro via proceso padre
-- (si el usuario no puede SELECT el proceso, tampoco accede a sus hijos)
DROP POLICY IF EXISTS "all_activities" ON public.activities;
CREATE POLICY "activities_via_process" ON public.activities
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
);
CREATE POLICY "activities_write" ON public.activities
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
AND public.current_platform_role() != 'client_viewer'
);
CREATE POLICY "activities_update" ON public.activities
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
AND public.current_platform_role() != 'client_viewer'
);
CREATE POLICY "activities_delete" ON public.activities
FOR DELETE USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
AND public.current_platform_role() IN ('platform_admin', 'member')
);
-- Mismo patrón para resources, simulations, activity_resource_assignments, gateways
-- (ver spec completo en el prompt de Etapa 1)
```
**Criterios Etapa 1:**
- [ ] Migraciones aplican sin error en Supabase Studio
- [ ] Tabla `organizations` existe con la fila de InQuality (`is_provider = true`)
- [ ] Procesos con `client` no vacío tienen `org_id` asignado
- [ ] Check constraint acepta los 5 roles (incluidos los nuevos)
- [ ] Un usuario `client_editor` con `org_id = X` puede SELECT procesos donde `org_id = X` y no otros
- [ ] Un usuario `client_viewer` no puede UPDATE activities
- [ ] `npm run test` → 552+ tests verdes (los tests de dominio no tocan DB)
---
### Etapa 2 — Edge Functions (invitar, suspender, eliminar usuarios)
Edge Functions corren en Deno (Supabase). La `service_role` key **nunca va al browser**. Crear la estructura:
```
supabase/functions/
├── invite-user/
│ └── index.ts
├── delete-user/
│ └── index.ts
└── suspend-user/
└── index.ts
```
**invite-user** (patrón exacto del doc de InQ Sizing, adaptado):
```typescript
// Validaciones en orden:
// 1. Verificar que el caller es platform_admin (leer profiles en DB, no confiar en JWT)
// 2. Verificar que la org destino existe y no es is_provider (evitar auto-elevación)
// 3. Invitar con inviteUserByEmail (manda email automático con magic link)
// 4. UPSERT del registro en public.users con onConflict: "id"
// (UPSERT, no INSERT — race condition con trigger handle_new_user si el email es @inquality.com.py)
await adminClient.auth.admin.inviteUserByEmail(body.email, {
data: { role: body.role, org_id: body.org_id }
})
await adminClient.from('users').upsert({
id: invited.user.id,
name: body.full_name,
platform_role: body.role, // 'client_editor' | 'client_viewer' | 'member'
org_id: body.org_id,
}, { onConflict: 'id' })
```
**delete-user:**
```typescript
// Validar caller = platform_admin, no puede eliminarse a sí mismo
await adminClient.auth.admin.deleteUser(body.user_id)
// ON DELETE CASCADE en public.users elimina la fila automáticamente
```
**suspend-user / unsuspend-user:**
```typescript
// Supabase ban: 876000h ≈ 100 años (equivalente a suspensión indefinida)
await adminClient.auth.admin.updateUserById(body.user_id, {
ban_duration: body.suspend ? '876000h' : 'none'
})
```
**Criterios Etapa 2:**
- [ ] `POST /functions/v1/invite-user` con payload válido crea usuario en Supabase Auth y fila en `public.users`
- [ ] `POST /functions/v1/delete-user` elimina el usuario y su fila (cascade)
- [ ] `POST /functions/v1/suspend-user` bloquea el login del usuario; `{ suspend: false }` lo restaura
- [ ] Un caller no-platform_admin recibe 403 en todas las funciones
- [ ] Invitar a una org con `is_provider = true` devuelve 400
---
### Etapa 3 — Capa de auth: email/password + fix de role desde DB
**3a — Fix crítico: leer `platform_role` desde DB (no inferir del email)**
Problema: `AuthContext.getUserFromStorage()` y `SupabaseAuthService.buildAuthUser()` asignan `platform_admin` si el email es `@inquality.com.py`, `guest` en cualquier otro caso. Un `client_editor` con email `@banco-solar.com` siempre sería `guest`.
Fix: extender `syncProfileFromDB` para leer también `platform_role`:
```typescript
// En SupabaseAuthService.syncProfileFromDB:
const { data } = await supabase
.from('users')
.select('name, company, platform_role, org_id') // agregar platform_role y org_id
.eq('id', userId)
.maybeSingle()
// En AuthContext, actualizar el estado con el rol real:
setUser(prev => prev?.id === userId
? { ...prev, company: data.company, name: data.name || prev.name,
platformRole: data.platform_role, orgId: data.org_id }
: prev
)
```
Actualizar `AuthUser` en `types.ts`:
```typescript
export interface AuthUser {
id: string
email: string
name: string
avatarUrl?: string
platformRole: 'platform_admin' | 'member' | 'client_editor' | 'client_viewer' | 'guest'
company?: string
orgId?: string // para routing y RLS del frontend
}
```
El `getUserFromStorage()` puede seguir usando la heurística del email como valor inicial optimista (evita el flash de login). El `syncProfileFromDB` lo corrige después del primer render.
**3b — Agregar `signInWithEmailPassword` a la capa de auth**
```typescript
// AuthService interface:
signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }>
// SupabaseAuthService implementation:
async signInWithEmailPassword(email: string, password: string) {
const { error } = await supabase.auth.signInWithPassword({ email, password })
return { error: error?.message ?? null }
}
```
**3c — Agregar guard TOKEN_REFRESHED en onAuthStateChange** (lección de InQ Sizing doc):
```typescript
// Problema: TOKEN_REFRESHED dispara cuando el usuario vuelve a la tab → loading=true brevemente → UI flashea
// Fix: solo setLoading en SIGNED_IN e INITIAL_SESSION
supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') {
// ... handle login
} else if (event === 'SIGNED_OUT') {
setUser(null)
setLoading(false)
}
// TOKEN_REFRESHED, USER_UPDATED → ignorar (la sesión ya está activa)
})
```
**3d — LoginPage: agregar form email/password**
La LoginPage actual solo tiene "Continuar con Google". Agregar:
- Form email/password debajo del botón Google, separado por "o"
- Validación Zod: email válido, contraseña no vacía
- Mensaje de error inline si `signInWithEmailPassword` retorna error
- Sin registro público — los clientes solo acceden por invitación
**3e — Página `/reset-password`**
Cuando el usuario acepta el magic link del invite, Supabase redirige a `/reset-password?access_token=...`. Esta página:
- Lee el token del hash/query
- Muestra form de nueva contraseña (mínimo 8 caracteres)
- Llama `supabase.auth.updateUser({ password: newPassword })`
- Redirige al home después del éxito
**Criterios Etapa 3:**
- [ ] Un `client_editor` con email no-InQuality puede iniciar sesión con email/password
- [ ] Después del `syncProfileFromDB`, `user.platformRole` es `client_editor` (no `guest`)
- [ ] `/reset-password` funciona con el magic link del invite
- [ ] Volver a una tab inactiva no causa flash de loading (TOKEN_REFRESHED ignorado)
- [ ] `npm run test` → mantener 552+ tests verdes
---
### Etapa 4 — Panel de administración de InQuality
Ruta: `/admin` — solo accesible a `platform_admin`.
**4a — Organizations page (`/admin/organizations`)**
Lista de organizaciones de clientes (todas las que no son InQuality):
- Nombre, fecha de creación, cantidad de procesos asignados, cantidad de usuarios
- Botón "Nueva organización" → modal/sheet con campo nombre
- Click en org → detalle: procesos asignados + usuarios de esa org
- Desde el detalle: asignar un proceso existente a la org (dropdown de procesos sin org asignada o reasignar)
**4b — Users page (`/admin/users`)**
Lista de todos los usuarios, filtrables por org:
- Nombre, email, rol, org, estado (activo/suspendido)
- Botón "Invitar usuario" → sheet con campos: email, nombre, rol (client_editor | client_viewer | member), org (obligatorio para roles de cliente)
- Al invitar: llama Edge Function `invite-user` → si OK, muestra confirmación "Email de invitación enviado"
- Menú de acciones por usuario: Cambiar rol, Suspender/Reactivar, Eliminar
- Suspender llama Edge Function `suspend-user`; Eliminar llama `delete-user` con confirmación
**4c — Entrada en AppHeader para admins**
Un ícono de escudo o ajuste en el AppHeader visible solo para `platform_admin`, que navega a `/admin`.
**Criterios Etapa 4:**
- [ ] `platform_admin` puede crear una organización y ver sus procesos/usuarios
- [ ] `platform_admin` puede invitar un usuario con rol `client_editor` a una org
- [ ] El usuario invitado recibe email con magic link
- [ ] `platform_admin` puede suspender y reactivar un usuario
- [ ] `platform_admin` puede eliminar un usuario (con confirmación)
- [ ] Un `member` o `client_editor` que navega a `/admin` es redirigido
- [ ] `npm run build` limpio
---
### Etapa 5 — UX de roles de cliente y routing
**5a — Routing con roles (TanStack Router)**
Extender el guard de `beforeLoad` del router para verificar el rol además de la sesión:
```typescript
// Rutas de workspace y biblioteca: platform_admin | member | client_editor
// Ruta de reporte: todos los roles autenticados (RLS filtra visibilidad)
// Rutas de admin: platform_admin únicamente
// Import BPMN: platform_admin | member únicamente
// En el beforeLoad de workspace/:processId:
if (user.platformRole === 'client_viewer') {
throw redirect({ to: '/report/$processId', params: { processId } })
}
```
**5b — WorkspacePage con `client_editor`**
`client_editor` accede al workspace completo con estas restricciones de UI:
- Ocultar el botón "Importar BPMN" (no puede crear procesos nuevos)
- El resto del workspace (ActivityPanel, recursos, simulación) funciona igual
`client_viewer` en workspace → redirigido automáticamente al reporte.
**5c — Library page con roles de cliente**
La biblioteca ya filtra por RLS — los `client_editor` y `client_viewer` ven solo sus procesos. Ajustar la UI:
- Ocultar el botón "Nuevo proceso" para roles de cliente
- Ocultar grupos/tags de organización interna (process_groups) si no son relevantes para el cliente
- El "grupo" de bienvenida puede mostrar el nombre de su organización
**5d — Proceso visible para `client_viewer` en reporte**
En `ReportPage`, cuando `user.platformRole === 'client_viewer'`:
- Tab activa por defecto: `roi` (no `actual`)
- Los demás tabs (Actual, Automatizado) son visibles pero las actividades están en modo solo lectura
- El ActivityPanel no se muestra (es del workspace, no del reporte)
**Criterios Etapa 5:**
- [ ] `client_editor` de Banco Solar ve solo los procesos de Banco Solar en la biblioteca
- [ ] `client_editor` puede editar parámetros de actividades, simular y exportar PDF
- [ ] `client_editor` no ve el botón "Importar BPMN"
- [ ] `client_viewer` que navega a `/workspace/:id` es redirigido a `/report/:id`
- [ ] `client_viewer` ve el reporte con tab ROI activo por defecto
- [ ] Un usuario de Banco Solar no puede ver procesos de Aseguradora Yacyretá (verificar con dos sesiones)
- [ ] Validación visual del director antes del OK formal
---
## Distribución del sprint
| Área | Etapas | % estimado |
|---|---|---|
| DB / infraestructura | 1, 2 | ~30% |
| Auth layer | 3 | ~20% |
| Admin UI | 4 | ~30% |
| UX cliente / routing | 5 | ~20% |
Sprint más grande hasta la fecha — comparable a Sprint 4 (refactorización arquitectural). Planear 6-8 sesiones de trabajo.
---
## Referencia de patrones de InQ Sizing aplicados
| Patrón | Dónde en InQ Sizing | Adaptación en InQ ROI |
|---|---|---|
| SECURITY DEFINER helpers | `0001_init.sql` | Migraciones 014-015, misma lógica |
| Trigger handle_new_user + ON CONFLICT DO NOTHING | `0003_google_oauth_trigger.sql` | Migración 015, mismo patrón |
| UPSERT en Edge Function (race condition fix) | `invite-user/index.ts` | Edge Function invite-user |
| Caller validado en DB, no en JWT | `invite-user/index.ts` | Todas las Edge Functions |
| guard TOKEN_REFRESHED en onAuthStateChange | `AuthContext.tsx` | Etapa 3c |
| hasOAuthCallback() en estado inicial | `AuthContext.tsx` | Ya implementado en InQ ROI |
| ProtectedRoute devuelve null mientras loading | `ProtectedRoute.tsx` | Adaptar a TanStack Router beforeLoad |
| GerenteGuard: redirigir antes de montar | `App.tsx` | beforeLoad de workspace route para client_viewer |
---
## Qué NO entra en Sprint 7
- Compartir procesos individuales con usuarios específicos (ya existe via `process_access` — no ampliar en este sprint)
- Autenticación centralizada entre InQ Sizing e InQ ROI (decisión Sprint 8+)
- Gestión de `process_groups` por clientes (solo InQuality usa grupos)
- i18n, modo offline, PWA
- Export Excel
- Historial de simulaciones
---
## Definition of Done del sprint
- [ ] Un usuario cliente puede aceptar el invite, setear contraseña y acceder al workspace de su org
- [ ] RLS verificada con dos sesiones simultáneas de orgs distintas — ningún dato cross-org visible
- [ ] `platform_admin` puede invitar, suspender y eliminar usuarios desde el panel admin
- [ ] `npm run build` limpio
- [ ] `npm run lint` sin warnings nuevos
- [ ] Tests Vitest: mantener o superar 552
- [ ] OBSERVATIONS.md con hallazgos del sprint
- [ ] STRATEGIC_DIRECTION.md actualizado al cerrar el sprint

View File

@@ -0,0 +1,430 @@
# Etapa 1 — Migraciones de base de datos + SECURITY DEFINER helpers
**Sprint:** 7
**Fecha:** 2026-06-24
**Alcance:** solo archivos SQL en `supabase/migrations/`. Cero cambios en frontend.
---
## Contexto
Esta etapa crea la fundación de datos sobre la que se construye todo el multi-usuario de Sprint 7. El esquema replica el patrón de InQ Sizing documentado en `cowork/InQ ROI - Simulador de procesos/autenticacion-y-usuarios.md` — leerlo antes de empezar.
**Schema actual relevante (no tocar lo que ya existe):**
- `public.users`: `id`, `name`, `avatar_url`, `platform_role` ('platform_admin'|'member'|'guest'), `company`, `org_id` (no existe aún), `created_at`
- `public.processes`: `client` (text — campo de display, se mantiene), `owner_id`, más campos de config. Sin `org_id` aún.
- `public.user_groups` + `group_memberships` + `public.process_access`: sistema de acceso interno de InQuality — NO tocar
- `public.is_platform_admin()`: función SECURITY DEFINER existente — NO tocar
---
## Archivo 1: `supabase/migrations/014_create_organizations.sql`
```sql
-- Tabla de organizaciones clientes
-- is_provider = true identifica a InQuality (org administradora de la plataforma)
CREATE TABLE IF NOT EXISTS public.organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
is_provider boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
-- Insertar la organización de InQuality como proveedor
-- ON CONFLICT DO NOTHING: idempotente si ya existe
INSERT INTO public.organizations (name, is_provider)
VALUES ('InQuality', true)
ON CONFLICT DO NOTHING;
-- Agregar org_id a users (nullable — usuarios InQuality existentes no tienen org de cliente)
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS org_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL;
-- Agregar org_id a processes (nullable — procesos existentes se vinculan por migración)
ALTER TABLE public.processes
ADD COLUMN IF NOT EXISTS org_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL;
-- Ampliar check constraint de platform_role para incluir roles de cliente
-- Primero eliminar la restricción existente, luego recrear con los valores nuevos
ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_platform_role_check;
ALTER TABLE public.users ADD CONSTRAINT users_platform_role_check
CHECK (platform_role IN ('platform_admin', 'member', 'client_editor', 'client_viewer', 'guest'));
-- ── Migración automática: crear orgs desde valores únicos de processes.client ──
-- Crea una organización por cada client name único y no vacío
-- ON CONFLICT DO NOTHING: si ya existe una org con ese nombre, no duplicar
INSERT INTO public.organizations (name, is_provider)
SELECT DISTINCT trim(client), false
FROM public.processes
WHERE client IS NOT NULL AND trim(client) != ''
ON CONFLICT (name) DO NOTHING;
-- Vincular procesos a sus orgs recién creadas
UPDATE public.processes p
SET org_id = o.id
FROM public.organizations o
WHERE trim(o.name) = trim(p.client)
AND o.is_provider = false
AND p.org_id IS NULL;
-- Asignar org_id de InQuality a usuarios InQuality existentes
-- (los que tienen email @inquality.com.py o platform_role = 'platform_admin'/'member')
UPDATE public.users u
SET org_id = (SELECT id FROM public.organizations WHERE is_provider = true LIMIT 1)
WHERE u.org_id IS NULL
AND u.platform_role IN ('platform_admin', 'member');
```
**Nota sobre el ON CONFLICT en organizations:** la tabla no tiene un UNIQUE constraint en `name` aún. Agregar uno:
```sql
-- Agregar constraint UNIQUE en name para que ON CONFLICT funcione
ALTER TABLE public.organizations ADD CONSTRAINT organizations_name_unique UNIQUE (name);
```
Este constraint va ANTES del INSERT de InQuality y de la migración automática.
---
## Archivo 2: `supabase/migrations/015_client_roles_and_rls.sql`
```sql
-- ── SECURITY DEFINER helpers ──────────────────────────────────────────────────
-- Retorna el org_id del usuario actual (null para usuarios sin org asignada)
CREATE OR REPLACE FUNCTION public.current_org()
RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER AS $$
SELECT org_id FROM public.users WHERE id = auth.uid()
$$;
-- Retorna true si el usuario pertenece a la organización proveedora (InQuality)
CREATE OR REPLACE FUNCTION public.is_inquality()
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $$
SELECT EXISTS (
SELECT 1 FROM public.users u
JOIN public.organizations o ON o.id = u.org_id
WHERE u.id = auth.uid() AND o.is_provider = true
)
$$;
-- Retorna el platform_role del usuario actual
CREATE OR REPLACE FUNCTION public.current_platform_role()
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER AS $$
SELECT platform_role FROM public.users WHERE id = auth.uid()
$$;
-- ── Trigger handle_new_user ───────────────────────────────────────────────────
-- Auto-crea fila en public.users para usuarios @inquality.com.py que se loguean
-- con Google OAuth. Los usuarios clientes se crean via Edge Function (invite),
-- no por este trigger.
-- ON CONFLICT (id) DO NOTHING: no sobrescribir si la Edge Function ya creó la fila.
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
provider_org_id uuid;
BEGIN
IF new.email LIKE '%@inquality.com.py' THEN
SELECT id INTO provider_org_id
FROM public.organizations
WHERE is_provider = true
LIMIT 1;
INSERT INTO public.users (id, name, avatar_url, platform_role, org_id)
VALUES (
new.id,
COALESCE(new.raw_user_meta_data->>'full_name', new.email),
new.raw_user_meta_data->>'avatar_url',
'member',
provider_org_id
)
ON CONFLICT (id) DO NOTHING;
END IF;
RETURN new;
END;
$$;
-- Crear trigger (DROP IF EXISTS para idempotencia)
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user();
-- ── RLS: organizations ────────────────────────────────────────────────────────
CREATE POLICY "orgs_select" ON public.organizations
FOR SELECT USING (
public.is_inquality()
OR id = public.current_org()
);
CREATE POLICY "orgs_insert" ON public.organizations
FOR INSERT WITH CHECK (public.is_platform_admin());
CREATE POLICY "orgs_update" ON public.organizations
FOR UPDATE USING (public.is_platform_admin());
CREATE POLICY "orgs_delete" ON public.organizations
FOR DELETE USING (public.is_platform_admin());
-- ── RLS: users ───────────────────────────────────────────────────────────────
-- Reemplazar la política permisiva existente
DROP POLICY IF EXISTS "read_users" ON public.users;
CREATE POLICY "users_select_own" ON public.users
FOR SELECT USING (id = auth.uid());
CREATE POLICY "users_select_admin" ON public.users
FOR SELECT USING (public.is_platform_admin());
-- ── RLS: processes ────────────────────────────────────────────────────────────
-- Reemplazar select y update para incluir acceso por org de cliente
DROP POLICY IF EXISTS "select_processes" ON public.processes;
CREATE POLICY "select_processes" ON public.processes
FOR SELECT USING (
public.is_platform_admin()
OR owner_id = auth.uid()
OR (
public.current_org() IS NOT NULL
AND org_id = public.current_org()
)
OR EXISTS (
SELECT 1 FROM public.process_access
WHERE process_id = processes.id
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR (grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
))
)
)
);
DROP POLICY IF EXISTS "insert_processes" ON public.processes;
CREATE POLICY "insert_processes" ON public.processes
FOR INSERT WITH CHECK (
public.is_platform_admin()
OR public.current_platform_role() = 'member'
-- client_editor y client_viewer NO pueden crear procesos nuevos
);
DROP POLICY IF EXISTS "update_processes" ON public.processes;
CREATE POLICY "update_processes" ON public.processes
FOR UPDATE USING (
public.is_platform_admin()
OR owner_id = auth.uid()
OR (
public.current_platform_role() = 'client_editor'
AND org_id = public.current_org()
)
OR EXISTS (
SELECT 1 FROM public.process_access
WHERE process_id = processes.id AND permission = 'edit'
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR (grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
))
)
)
);
-- ── RLS: activities ──────────────────────────────────────────────────────────
-- Reemplazar política permisiva "all_activities" por filtro via proceso padre
DROP POLICY IF EXISTS "all_activities" ON public.activities;
-- SELECT: si el usuario puede ver el proceso padre, puede ver sus actividades
CREATE POLICY "activities_select" ON public.activities
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
);
-- INSERT / UPDATE: InQuality + client_editor (no client_viewer)
CREATE POLICY "activities_insert" ON public.activities
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "activities_update" ON public.activities
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
-- DELETE: solo InQuality
CREATE POLICY "activities_delete" ON public.activities
FOR DELETE USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
AND public.current_platform_role() IN ('platform_admin', 'member')
);
-- ── RLS: resources ────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS "all_resources" ON public.resources;
CREATE POLICY "resources_select" ON public.resources
FOR SELECT USING (
public.is_inquality()
OR (
process_id IS NULL -- recursos del catálogo global: visibles a todos autenticados
AND auth.uid() IS NOT NULL
)
OR EXISTS (SELECT 1 FROM public.processes WHERE id = resources.process_id)
);
CREATE POLICY "resources_insert" ON public.resources
FOR INSERT WITH CHECK (
auth.uid() IS NOT NULL
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "resources_update" ON public.resources
FOR UPDATE USING (
auth.uid() IS NOT NULL
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "resources_delete" ON public.resources
FOR DELETE USING (
public.current_platform_role() IN ('platform_admin', 'member')
);
-- ── RLS: simulations ─────────────────────────────────────────────────────────
DROP POLICY IF EXISTS "all_simulations" ON public.simulations;
CREATE POLICY "simulations_select" ON public.simulations
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id)
);
CREATE POLICY "simulations_insert" ON public.simulations
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "simulations_delete" ON public.simulations
FOR DELETE USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id)
AND public.current_platform_role() IN ('platform_admin', 'member')
);
-- ── RLS: activity_resource_assignments ───────────────────────────────────────
-- Heredan acceso del proceso via actividad
DROP POLICY IF EXISTS "all_activity_resource_assignments" ON public.activity_resource_assignments;
CREATE POLICY "ara_select" ON public.activity_resource_assignments
FOR SELECT USING (
EXISTS (
SELECT 1 FROM public.activities a
JOIN public.processes p ON p.id = a.process_id
WHERE a.id = activity_resource_assignments.activity_id
)
);
CREATE POLICY "ara_write" ON public.activity_resource_assignments
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM public.activities a
JOIN public.processes p ON p.id = a.process_id
WHERE a.id = activity_resource_assignments.activity_id
)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "ara_update" ON public.activity_resource_assignments
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM public.activities a
JOIN public.processes p ON p.id = a.process_id
WHERE a.id = activity_resource_assignments.activity_id
)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "ara_delete" ON public.activity_resource_assignments
FOR DELETE USING (
EXISTS (
SELECT 1 FROM public.activities a
JOIN public.processes p ON p.id = a.process_id
WHERE a.id = activity_resource_assignments.activity_id
)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
-- ── RLS: gateways ────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS "all_gateways" ON public.gateways;
CREATE POLICY "gateways_select" ON public.gateways
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id)
);
CREATE POLICY "gateways_write" ON public.gateways
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
CREATE POLICY "gateways_update" ON public.gateways
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
```
---
## Qué verificar antes de aplicar en producción
1. **En Supabase Studio (SQL Editor):** ejecutar primero en una rama o en el proyecto de staging si existe. Las migraciones son destructivas en las policies RLS existentes (DROP POLICY).
2. **Verificar nombres exactos de las policies a eliminar:** los `DROP POLICY IF EXISTS` son seguros (no fallan si no existen), pero verificar que los nombres coincidan con los de las migraciones anteriores.
3. **Verificar tabla `activity_resource_assignments`:** confirmar que existe con ese nombre exacto (puede ser `activity_resource_assignments` o similar según migration 007).
4. **Verificar tabla `gateways`:** confirmar que existe y tiene política existente que eliminar.
---
## Criterios de validación
- [ ] Migration 014 aplica sin error en Supabase Studio
- [ ] Migration 015 aplica sin error
- [ ] `public.organizations` existe con fila InQuality (`is_provider = true`)
- [ ] Procesos con `client` no vacío tienen `org_id` asignado (verificar en tabla)
- [ ] Usuarios existentes de InQuality tienen `org_id` apuntando a la org de InQuality
- [ ] Check constraint acepta 'client_editor' y 'client_viewer' (test: UPDATE manual de un usuario)
- [ ] `SELECT public.current_org()` retorna el `org_id` del usuario autenticado
- [ ] `SELECT public.is_inquality()` retorna true para usuario InQuality, false para usuario sin org
- [ ] `SELECT public.current_platform_role()` retorna el rol correcto
- [ ] Trigger `on_auth_user_created` existe en `auth.users`
- [ ] Un usuario con `platform_role = 'client_viewer'` NO puede INSERT en activities (verificar con RLS test en Studio)
- [ ] `npm run test` → 552+ tests verdes (los tests de dominio/UI no tocan DB directamente)
- [ ] `npm run build` limpio (ningún cambio de TypeScript en esta etapa)
---
## Archivos a crear
- `supabase/migrations/014_create_organizations.sql`
- `supabase/migrations/015_client_roles_and_rls.sql`
## NO modificar
- Ningún archivo TypeScript / React
- Ningún archivo de tests existente
- `supabase/migrations/001` a `013` — son historia inmutable
- `public.user_groups`, `public.group_memberships`, `public.process_access` — sistema interno de InQuality, no tocar sus políticas

View File

@@ -0,0 +1,273 @@
# Etapa 2 — Edge Function: invitación de usuarios cliente
**Sprint:** 7
**Fecha:** 2026-07-06
**Alcance:** Edge Function de Supabase + endpoint de revocación. Cero cambios en frontend. Cero cambios en migraciones existentes (001-015 son historia inmutable).
---
## Contexto
Las migraciones 014-015 ya están aplicadas en producción:
- `public.organizations` existe con InQuality (is_provider=true)
- `public.users.org_id` + `public.users.platform_role` con 4 roles: platform_admin, member, client_editor, client_viewer
- SECURITY DEFINER helpers: `current_org()`, `is_inquality()`, `current_platform_role()`
- RLS activo en todas las tablas
Esta etapa crea el flujo de invitación: un `platform_admin` llama a una Edge Function que:
1. Crea el usuario en `auth.users` de Supabase (vía Admin API)
2. Crea o reutiliza la organización cliente
3. Inserta la fila en `public.users` con el rol correcto y org_id
4. Envía el email de bienvenida vía Supabase Auth inviteUserByEmail
El patrón es el mismo que InQ Sizing (`autenticacion-y-usuarios.md`), adaptado al schema de InQ ROI.
---
## Estructura de archivos a crear
```
supabase/
└── functions/
├── invite-user/
│ └── index.ts ← Edge Function principal
└── revoke-user/
└── index.ts ← Edge Function de revocación/suspensión
```
---
## Edge Function: `invite-user`
### Request
```typescript
// POST /functions/v1/invite-user
// Authorization: Bearer <access_token del platform_admin>
{
email: string, // email del usuario a invitar
name: string, // nombre completo
platform_role: 'client_editor' | 'client_viewer' | 'member',
org_name: string, // nombre de la organización (se crea si no existe)
// Si platform_role = 'member', org_name se ignora → org será InQuality
}
```
### Validaciones
- Solo `platform_admin` puede llamar esta función. Verificar con `adminClient.auth.getUser(token)` + consulta a `public.users` para confirmar `platform_role = 'platform_admin'`.
- `email` requerido, formato válido.
- `platform_role` debe ser uno de los tres valores permitidos.
- `org_name` requerido si `platform_role` es `client_editor` o `client_viewer`.
- No invitar emails que ya existen en `auth.users` (verificar antes).
### Lógica
```typescript
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const adminClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// 1. Autenticar al llamador
const callerToken = req.headers.get('Authorization')?.replace('Bearer ', '')
const { data: { user: caller } } = await adminClient.auth.getUser(callerToken)
const { data: callerProfile } = await adminClient
.from('users')
.select('platform_role')
.eq('id', caller.id)
.single()
if (callerProfile?.platform_role !== 'platform_admin') {
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 })
}
// 2. Resolver org_id
let orgId: string
if (platform_role === 'member') {
// Nuevo miembro InQuality → org es InQuality
const { data: inqOrg } = await adminClient
.from('organizations')
.select('id')
.eq('is_provider', true)
.single()
orgId = inqOrg.id
} else {
// Usuario cliente → buscar o crear org por nombre
const { data: existingOrg } = await adminClient
.from('organizations')
.select('id')
.eq('name', org_name.trim())
.single()
if (existingOrg) {
orgId = existingOrg.id
} else {
const { data: newOrg } = await adminClient
.from('organizations')
.insert({ name: org_name.trim(), is_provider: false })
.select('id')
.single()
orgId = newOrg.id
}
}
// 3. Invitar usuario vía Auth Admin API (envía email automáticamente)
const { data: inviteData, error: inviteError } = await adminClient.auth.admin.inviteUserByEmail(
email,
{
data: { full_name: name }, // raw_user_meta_data
redirectTo: `${Deno.env.get('SITE_URL')}/auth/callback`
}
)
if (inviteError) {
return new Response(JSON.stringify({ error: inviteError.message }), { status: 400 })
}
// 4. Crear fila en public.users
// UPSERT (no INSERT) para manejar race condition: si el trigger handle_new_user
// ya corrió (emails @inquality.com.py), no duplicar.
const { error: upsertError } = await adminClient
.from('users')
.upsert({
id: inviteData.user.id,
name,
platform_role,
org_id: orgId,
}, { onConflict: 'id', ignoreDuplicates: false })
if (upsertError) {
return new Response(JSON.stringify({ error: upsertError.message }), { status: 500 })
}
return new Response(JSON.stringify({ success: true, userId: inviteData.user.id }), { status: 200 })
```
### Headers de respuesta
Siempre incluir:
```typescript
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': Deno.env.get('SITE_URL') ?? '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
```
Manejar OPTIONS (CORS preflight):
```typescript
if (req.method === 'OPTIONS') {
return new Response('ok', { headers })
}
```
---
## Edge Function: `revoke-user`
### Request
```typescript
// POST /functions/v1/revoke-user
// Authorization: Bearer <access_token del platform_admin>
{
userId: string // UUID del usuario a revocar
}
```
### Lógica
```typescript
// 1. Verificar que el llamador es platform_admin (igual que invite-user)
// 2. No permitir auto-revocación
if (userId === caller.id) {
return new Response(JSON.stringify({ error: 'No puedes revocar tu propio acceso' }), { status: 400 })
}
// 3. Cambiar platform_role a 'guest' (suspensión suave, no elimina datos)
const { error } = await adminClient
.from('users')
.update({ platform_role: 'guest' })
.eq('id', userId)
if (error) {
return new Response(JSON.stringify({ error: error.message }), { status: 500 })
}
// 4. Opcionalmente: revocar sesiones activas
await adminClient.auth.admin.signOut(userId)
return new Response(JSON.stringify({ success: true }), { status: 200 })
```
---
## Variables de entorno requeridas
Las Edge Functions de Supabase tienen acceso automático a:
- `SUPABASE_URL`
- `SUPABASE_SERVICE_ROLE_KEY`
- `SUPABASE_ANON_KEY`
Agregar manualmente en Supabase Dashboard → Edge Functions → Secrets:
- `SITE_URL` = `https://inq-roi.inqualityhq.com`
Documentar esto en un comentario al tope de cada función.
---
## TypeScript — tipado estricto
```typescript
interface InviteUserRequest {
email: string
name: string
platform_role: 'client_editor' | 'client_viewer' | 'member'
org_name?: string
}
interface RevokeUserRequest {
userId: string
}
```
Validar con guardas de tipo antes de proceder. Retornar errores descriptivos en español para consumo interno.
---
## Criterios de validación
- [ ] `supabase/functions/invite-user/index.ts` existe y compila (Deno/TypeScript)
- [ ] `supabase/functions/revoke-user/index.ts` existe y compila
- [ ] Llamada sin Authorization header → 401
- [ ] Llamada con token de usuario no-admin → 403
- [ ] Llamada con email ya existente → error descriptivo (no crash)
- [ ] Llamada con platform_role inválido → error descriptivo
- [ ] `npm run test` → 552+ tests verdes (ningún test nuevo requerido en esta etapa — el E2E de invite requiere despliegue real)
- [ ] `npm run build` limpio
## Documentar en comentario al tope de cada función
```typescript
/**
* Edge Function: invite-user
* Propósito: invitar usuario externo (client_editor/client_viewer) o interno (member)
* Solo accesible por platform_admin.
* Variables de entorno requeridas: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SITE_URL
* Deploy: supabase functions deploy invite-user
*/
```
---
## NO modificar
- Ningún archivo de migración (001-015 son historia inmutable)
- Ningún archivo TypeScript/React del frontend
- Ningún test existente
- Las tablas `user_groups`, `group_memberships`, `process_access` y sus policies

View File

@@ -0,0 +1,293 @@
# Etapa 3 — Capa de auth: fix de rol desde DB + email/password + reset-password
**Sprint:** 7
**Fecha:** 2026-07-06
**Alcance:** capa de auth (frontend + router). Sin cambios en Edge Functions ni migraciones.
---
## Contexto y problema a resolver
`SupabaseAuthService.buildAuthUser()` infiere el rol del email: `@inquality.com.py``platform_admin`, cualquier otro → `guest`. Esto rompe los nuevos roles: un `client_editor` con email `@banco-solar.com` siempre sería `guest` y no podría acceder a nada.
El fix: `syncProfileFromDB` (o equivalente) debe leer `platform_role` y `org_id` desde `public.users` y actualizar el estado del usuario. La heurística del email puede sobrevivir como valor inicial optimista, pero el rol real viene de la DB.
**ANTES de modificar cualquier archivo:** leer el código actual de:
- `src/features/auth/SupabaseAuthService.ts` (o donde viva `buildAuthUser`, `syncProfileFromDB`, `signInWithGoogle`)
- `src/features/auth/AuthContext.tsx` (o `AuthStore.tsx`)
- `src/domain/types.ts` (para ver la interface `AuthUser` actual)
- `src/router.tsx` (para entender el patrón de rutas existente)
Adaptar el fix según lo que realmente existe — los nombres de métodos y archivos pueden diferir del BRIEF.
---
## 3a — Fix crítico: leer `platform_role` y `org_id` desde DB
### Cambios en `src/domain/types.ts`
Extender `AuthUser` para incluir `orgId`:
```typescript
export interface AuthUser {
id: string
email: string
name: string
avatarUrl?: string
platformRole: 'platform_admin' | 'member' | 'client_editor' | 'client_viewer' | 'guest'
company?: string
orgId?: string // uuid de la organización del usuario — null para usuarios sin org
}
```
Si `AuthUser` ya existe con campos distintos, no eliminar campos existentes — solo agregar `orgId`.
### Cambios en `SupabaseAuthService` (o donde viva `syncProfileFromDB`)
Extender el SELECT para incluir `platform_role` y `org_id`:
```typescript
const { data } = await supabase
.from('users')
.select('name, company, platform_role, org_id')
.eq('id', userId)
.maybeSingle()
if (data) {
// Retornar o despachar con los nuevos campos
return {
name: data.name,
company: data.company ?? undefined,
platformRole: data.platform_role as AuthUser['platformRole'],
orgId: data.org_id ?? undefined,
}
}
```
### Cambios en `AuthContext` (o donde se llame `syncProfileFromDB`)
Cuando se recibe el resultado de `syncProfileFromDB`, actualizar TODOS los campos incluyendo `platformRole` y `orgId`:
```typescript
setUser(prev => prev
? {
...prev,
name: profile.name || prev.name,
company: profile.company,
platformRole: profile.platformRole, // ← reemplaza la heurística de email
orgId: profile.orgId,
}
: prev
)
```
### Mantener heurística de email como valor inicial (optimista)
`buildAuthUser()` puede seguir usando el email para un valor inicial mientras se carga el perfil de DB. El `syncProfileFromDB` lo corrige después. Esto evita el flash de "sin acceso" durante la carga inicial.
---
## 3b — Agregar `signInWithEmailPassword` a la capa de auth
### Interface `AuthService` (o equivalente)
Agregar método:
```typescript
signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }>
```
### Implementación en `SupabaseAuthService`
```typescript
async signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }> {
const { error } = await supabase.auth.signInWithPassword({ email, password })
return { error: error?.message ?? null }
}
```
### Exponer en el contexto de auth
Si hay un hook `useAuth()` o similar, agregar `signInWithEmailPassword` al objeto retornado. Si no, exponerlo directamente desde el contexto.
---
## 3c — Guard TOKEN_REFRESHED en `onAuthStateChange`
Modificar el listener de `onAuthStateChange` para no disparar loading innecesariamente en TOKEN_REFRESHED:
```typescript
supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'TOKEN_REFRESHED' || event === 'USER_UPDATED') {
// No cambiar loading ni disparar sync — la sesión ya está activa
return
}
if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') {
// ... lógica de login existente
} else if (event === 'SIGNED_OUT') {
setUser(null)
setLoading(false)
}
})
```
**Nota:** este fix puede resolver el bug de "workspace loading — se queda cargando sin refresh" reportado en Sprint 5 si ese bug se manifestaba al volver a la tab. Verificar si es el caso después de aplicar.
---
## 3d — LoginPage: agregar form email/password
La `LoginPage` actual solo tiene el botón "Continuar con Google". Agregar un formulario debajo:
### Layout
```
[ Continuar con Google ]
─────── o ─────────────────
[ Email ]
[ Contraseña ]
[ Ingresar ]
Hint: Los usuarios externos acceden por invitación.
Si no recibiste uno, contactá a tu consultor InQuality.
```
### Validación (Zod)
```typescript
const loginSchema = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(1, 'La contraseña es requerida'),
})
```
### Comportamiento
- `type="submit"` en el form, no `type="button"` — para que Enter funcione
- Mostrar error inline debajo del form si `signInWithEmailPassword` retorna error (no toast, no alert)
- Spinner o estado disabled en el botón mientras se procesa
- En caso de error: el mensaje de Supabase en inglés es aceptable, o traducir si es simple ("Invalid login credentials" → "Email o contraseña incorrectos")
- No registrar usuarios nuevos — solo login. Sin link "Crear cuenta".
### Estilo
Usar los componentes shadcn/ui existentes: `Input`, `Button`, `Label`. El separador "o" puede ser un `<Separator />` con texto superpuesto o simplemente un `<p>` centrado con estilo. No introducir librerías nuevas.
---
## 3e — Página `/reset-password` (set-password post-invite)
Cuando un usuario acepta el magic link de invitación, Supabase redirige a `{SITE_URL}/auth/callback` y luego a una URL con `access_token` en el hash. La app debe capturar esto y llevarlo a una página donde el usuario setea su contraseña.
### Nueva ruta
Agregar `/reset-password` en `src/router.tsx`. Esta ruta es pública (no requiere sesión activa).
### Nuevo componente `src/features/auth/ResetPasswordPage.tsx`
```typescript
// Leer el token del hash de la URL
// Supabase lo procesa automáticamente vía onAuthStateChange con event = PASSWORD_RECOVERY
// El flujo:
// 1. El usuario hace click en el link del email
// 2. Supabase procesa el token → onAuthStateChange dispara PASSWORD_RECOVERY con sesión
// 3. La app detecta PASSWORD_RECOVERY y redirige a /reset-password
// 4. ResetPasswordPage muestra el form de nueva contraseña
// En onAuthStateChange (en AuthContext):
if (event === 'PASSWORD_RECOVERY') {
navigate({ to: '/reset-password' })
return
}
```
```typescript
// ResetPasswordPage.tsx
const ResetPasswordPage = () => {
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (password !== confirm) {
setError('Las contraseñas no coinciden')
return
}
if (password.length < 8) {
setError('La contraseña debe tener al menos 8 caracteres')
return
}
setLoading(true)
const { error } = await supabase.auth.updateUser({ password })
if (error) {
setError(error.message)
setLoading(false)
} else {
navigate({ to: '/' })
}
}
return (
// Form con dos campos: nueva contraseña + confirmar, botón "Establecer contraseña"
// Misma estructura visual que LoginPage
)
}
```
### Agregar `PASSWORD_RECOVERY` al guard de `onAuthStateChange`
```typescript
if (event === 'PASSWORD_RECOVERY') {
// Sesión activa pero el usuario necesita setear contraseña
// Redirigir a /reset-password sin setear el user en estado
navigate({ to: '/reset-password' })
return
}
```
---
## Tests nuevos (mínimo requerido)
```typescript
// tests/features/auth/auth-role-sync.test.ts
// - buildAuthUser con email @inquality.com.py retorna platformRole optimista
// - syncProfileFromDB con { platform_role: 'client_editor' } actualiza el estado
// - syncProfileFromDB con { platform_role: 'platform_admin' } (DB) sobrescribe heurística de email
// - AuthUser con orgId seteado retiene el campo tras sync
// NO requiere tests E2E en esta etapa — el login real se prueba manualmente
// con un usuario invitado en producción
```
---
## Criterios de validación
- [ ] `AuthUser` tiene el campo `orgId?: string`
- [ ] `syncProfileFromDB` hace SELECT con `platform_role, org_id`
- [ ] Después del sync, `user.platformRole` refleja el valor de DB (no la heurística de email)
- [ ] `signInWithEmailPassword` existe en la capa de auth y funciona con Supabase
- [ ] `LoginPage` muestra el form email/password separado del botón Google
- [ ] `/reset-password` existe como ruta pública, muestra el form de nueva contraseña
- [ ] `TOKEN_REFRESHED` en `onAuthStateChange` no dispara `setLoading(true)`
- [ ] `PASSWORD_RECOVERY` en `onAuthStateChange` redirige a `/reset-password`
- [ ] `npm run test` → 552+ tests verdes (más los nuevos de auth-role-sync)
- [ ] `npm run build` limpio
---
## NO modificar
- Edge Functions (supabase/functions/)
- Archivos de migración (001-015)
- WorkspacePage, ReportPage, LibraryPage — los cambios de UI de roles van en Etapa 5
- Panel de admin — va en Etapa 4
- Lógica de simulación, dominio, y cálculo de ROI

View File

@@ -0,0 +1,288 @@
# Etapa 4 — Panel de administración de InQuality
**Sprint:** 7
**Fecha:** 2026-07-06
**Alcance:** nuevas rutas `/admin/*` con guard de rol + componentes de admin. Sin cambios en migraciones, Edge Functions, WorkspacePage, ReportPage, o LibraryPage.
---
## Contexto
Las Edge Functions `invite-user` y `revoke-user` ya están desplegadas (Etapa 2). Esta etapa construye la UI que las consume. El acceso es exclusivo para `platform_admin`.
**ANTES de empezar:** leer `src/router.tsx` para entender el patrón de rutas y guards existentes (`beforeLoad`). La estructura de archivos debe seguir el patrón de `features/` ya establecido.
---
## Nuevas rutas
```
/admin → redirect a /admin/organizations
/admin/organizations → lista de orgs clientes
/admin/organizations/$orgId → detalle de org (procesos + usuarios)
/admin/users → lista de todos los usuarios
```
Todas requieren `platform_role === 'platform_admin'` — si no, redirect a `/`.
### Guard en TanStack Router (patrón `beforeLoad`)
```typescript
// Crear layout route para /admin que incluya el guard:
beforeLoad: ({ context }) => {
const user = context.auth?.user
if (!user || user.platformRole !== 'platform_admin') {
throw redirect({ to: '/' })
}
}
```
---
## Layout de admin
**No crear un layout nuevo de toda la app.** Usar el `AppHeader` existente. Las páginas de admin son pages normales con su propia estructura interna.
Estructura interna sugerida para cada page de admin:
```
<div className="max-w-6xl mx-auto px-6 py-8">
{/* Nav tabs entre Organizations y Users */}
<div className="flex gap-4 border-b mb-6">
<NavLink to="/admin/organizations">Organizaciones</NavLink>
<NavLink to="/admin/users">Usuarios</NavLink>
</div>
{/* Contenido de la page */}
</div>
```
---
## Ícono admin en AppHeader
Agregar un ícono `Shield` (Lucide) visible solo cuando `user.platformRole === 'platform_admin'`. Posición: junto al avatar/dropdown, a la izquierda.
```tsx
{user.platformRole === 'platform_admin' && (
<Button variant="ghost" size="icon" asChild>
<Link to="/admin">
<Shield className="h-5 w-5 text-muted-foreground" />
</Link>
</Button>
)}
```
---
## Página Organizations (`/admin/organizations`)
### Lista de organizaciones (clientes únicamente — `is_provider = false`)
Query:
```typescript
const { data: orgs } = await supabase
.from('organizations')
.select(`
id, name, created_at,
processes:processes(count),
users:users(count)
`)
.eq('is_provider', false)
.order('name')
```
Columnas de la tabla:
| Organización | Procesos | Usuarios | Creada | Acciones |
|---|---|---|---|---|
| Banco Solar | 3 | 2 | 12 jun 2026 | Ver detalle |
### Crear organización
Botón "Nueva organización" → Sheet (no modal) con:
- Campo `Nombre` (requerido, trim, no vacío)
- Botón "Crear" → `supabase.from('organizations').insert({ name, is_provider: false })`
- Al crear: cerrar sheet + refetch lista
### Detalle de organización (`/admin/organizations/$orgId`)
Dos secciones en tabs o acordeón:
**Procesos de esta org:**
```typescript
const { data: processes } = await supabase
.from('processes')
.select('id, name, client, updated_at')
.eq('org_id', orgId)
.order('updated_at', { ascending: false })
```
Mostrar como lista simple: nombre del proceso, fecha de última actualización, link al workspace.
Botón "Asignar proceso existente" → Sheet con dropdown de procesos sin org asignada (`org_id IS NULL`) + botón asignar:
```typescript
await supabase
.from('processes')
.update({ org_id: orgId })
.eq('id', selectedProcessId)
```
**Usuarios de esta org:**
```typescript
const { data: users } = await supabase
.from('users')
.select('id, name, company, platform_role')
.eq('org_id', orgId)
.order('name')
```
Mostrar como lista: nombre, rol, acciones (ver Usuarios page para acciones).
---
## Página Users (`/admin/users`)
### Lista de usuarios
```typescript
const { data: users } = await supabase
.from('users')
.select(`
id, name, company, platform_role, org_id,
organization:organizations(name, is_provider)
`)
.order('name')
```
Columnas:
| Nombre | Email* | Rol | Organización | Estado | Acciones |
|---|---|---|---|---|---|
*Note: `email` está en `auth.users`, no en `public.users`. Para esta etapa, omitir el email en la lista o usar `name` únicamente. El email se puede agregar en sprint futuro via Edge Function o join con `auth.users` (solo disponible con service_role).
**Badges de rol con color:**
- `platform_admin` → badge naranja (`#F59845`)
- `member` → badge slate
- `client_editor` → badge azul
- `client_viewer` → badge verde claro
- `guest` → badge gris, con indicador visual de "suspendido"
### Filtro por organización
Dropdown "Filtrar por org" encima de la tabla. Opción "Todas". Al seleccionar una org, filtra con `.eq('org_id', selectedOrgId)`.
### Invitar usuario
Botón "Invitar usuario" → Sheet con campos:
```
Nombre completo: [__________________]
Email: [__________________]
Rol: [client_editor ▾] (opciones: client_editor, client_viewer, member)
Organización: [Banco Solar ▾] (solo si rol es client_editor o client_viewer)
```
Al submit → llamar Edge Function:
```typescript
const { data, error } = await supabase.functions.invoke('invite-user', {
body: {
email,
name,
platform_role: rol,
org_name: selectedOrg?.name ?? '',
}
})
if (error) {
// mostrar error inline
} else {
// "Invitación enviada a {email}" — toast o inline
// cerrar sheet + refetch lista
}
```
**Nota:** la Edge Function `invite-user` recibe `org_name` (string), no `org_id`. Busca o crea la org internamente.
### Acciones por usuario (menú de 3 puntos o dropdown)
```
[ Cambiar rol ] → Sheet con dropdown de roles + botón guardar
→ supabase.from('users').update({ platform_role: nuevoRol }).eq('id', userId)
[ Suspender ] → cambia platform_role a 'guest' vía Edge Function revoke-user
Texto de confirmación: "¿Suspender a {nombre}? No podrá acceder a la plataforma."
[ Reactivar ] → visible solo si platform_role === 'guest'
→ supabase.from('users').update({ platform_role: 'client_editor' }).eq('id', userId)
IMPORTANTE: al reactivar, restaurar al rol anterior. Como no lo tenemos guardado,
restaurar a 'client_editor' como default. Documentar esto como limitación.
[ Eliminar ] → confirmación con texto del nombre del usuario
→ supabase.functions.invoke('revoke-user', { body: { userId } })
Nota: la función actual baja el rol a 'guest'. Eliminar real queda para sprint futuro.
Mostrar al admin: "Usuario suspendido permanentemente (eliminación real en versión futura)".
```
**No permitir acciones sobre el usuario propio del admin** — deshabilitar las acciones si `user.id === item.id`.
---
## Versión wow (requerida si costo < 40%)
- Loading skeletons en la tabla mientras carga (usar `Skeleton` de shadcn)
- Empty state ilustrado: cuando no hay usuarios en la org, mostrar texto + icono, no tabla vacía
- Confirmación de acciones destructivas con `AlertDialog` de shadcn (no `window.confirm`)
- Badge de estado en la fila del usuario invitado recientemente: "Invitación pendiente" si el usuario no tiene `name` o `avatar_url` (indicador de que no aceptó el invite aún)
- En el sheet de invite: deshabilitar el campo "Organización" cuando el rol es `member`, mostrar "InQuality" como valor fijo
---
## Archivos a crear
```
src/features/admin/
├── AdminLayout.tsx ← nav tabs, guard visual
├── OrganizationsPage.tsx ← lista + crear
├── OrganizationDetailPage.tsx ← procesos + usuarios de la org
└── UsersPage.tsx ← lista + invite + acciones
```
Agregar en `src/router.tsx` las 4 rutas con `beforeLoad` de guard.
---
## Criterios de validación
- [ ] Navegar a `/admin` sin ser `platform_admin` redirige a `/`
- [ ] `platform_admin` ve el ícono Shield en AppHeader → navega a `/admin`
- [ ] Lista de organizaciones muestra orgs clientes (no InQuality)
- [ ] Crear organización → aparece en la lista sin recargar la página
- [ ] Detalle de org muestra procesos y usuarios de esa org
- [ ] Asignar proceso a org → proceso aparece en el detalle
- [ ] Lista de usuarios muestra todos con badge de rol correcto
- [ ] Filtro por org funciona
- [ ] Invitar usuario llama la Edge Function y muestra confirmación
- [ ] Suspender usuario: confirmación con AlertDialog, cambio a 'guest' efectivo
- [ ] No se puede ejecutar ninguna acción sobre el propio usuario admin
- [ ] Loading skeletons visibles durante fetch inicial
- [ ] `npm run test` → 558+ tests verdes
- [ ] `npm run build` limpio
- [ ] **Validación visual del director antes del OK formal**
---
## NO modificar
- `WorkspacePage`, `ReportPage`, `LibraryPage`
- Migraciones (001-015)
- Edge Functions
- Lógica de dominio, simulación, ROI
- Componentes de PDF/CSV
---
## Limitaciones conocidas a documentar en comentarios
1. `email` no disponible en `public.users` — la columna vive en `auth.users` que requiere service_role. Mostrar solo `name` por ahora.
2. "Eliminar" usuario usa `revoke-user` (baja a 'guest'). La eliminación real requiere una Edge Function `delete-user` futura.
3. "Reactivar" restaura siempre a `client_editor` — no se guarda el rol previo a la suspensión.

View File

@@ -0,0 +1,197 @@
# Etapa 5 — UX de roles de cliente y routing
**Sprint:** 7
**Fecha:** 2026-07-06
**Alcance:** guards de rol en router + ajustes de UI en LibraryPage, WorkspacePage y ReportPage. Sin cambios en migraciones, Edge Functions, o panel admin.
---
## Contexto
Las Etapas 1-4 construyeron la infraestructura. Esta etapa cierra la experiencia de usuario para `client_editor` y `client_viewer`:
- El router impide que `client_viewer` entre al workspace
- La Library oculta elementos internos de InQuality (crear proceso, grupos)
- El workspace oculta "Importar BPMN" para `client_editor`
- El reporte arranca en la tab ROI para `client_viewer`
La RLS ya filtra los datos (un `client_editor` de Banco Solar solo ve procesos de Banco Solar). Esta etapa solo ajusta la UI para que el comportamiento sea coherente con esa realidad de datos.
**ANTES de empezar:** leer el código actual de `src/router.tsx`, `LibraryPage.tsx`, `WorkspacePage.tsx` y `ReportPage.tsx`. Los nombres exactos de componentes y props pueden diferir del BRIEF.
---
## 5a — Guards de rol en el router
### Workspace route (`/workspace/$processId`)
`client_viewer` no puede editar → redirigir al reporte:
```typescript
beforeLoad: ({ context, params }) => {
const user = context.auth?.user
if (!user) throw redirect({ to: '/login' })
if (user.platformRole === 'client_viewer') {
throw redirect({ to: '/report/$processId', params: { processId: params.processId } })
}
}
```
### Import BPMN route (si existe como ruta separada)
Si hay una ruta `/import` o similar: solo `platform_admin` y `member` pueden acceder. `client_editor` y `client_viewer` → redirect a `/`.
```typescript
beforeLoad: ({ context }) => {
const user = context.auth?.user
const allowed = ['platform_admin', 'member']
if (!user || !allowed.includes(user.platformRole)) {
throw redirect({ to: '/' })
}
}
```
### Rutas de admin (verificar que ya están protegidas)
Las rutas `/admin/*` ya tienen guard de Etapa 4. Solo verificar que siguen funcionando después de los cambios de esta etapa.
---
## 5b — WorkspacePage: ocultar "Importar BPMN" para roles de cliente
El botón o trigger de importación de BPMN solo debe aparecer para `platform_admin` y `member`.
```typescript
const { user } = useAuth()
const canImport = user?.platformRole === 'platform_admin' || user?.platformRole === 'member'
// En el JSX:
{canImport && <ImportBpmnButton />} // o el componente/botón equivalente
```
No deshabilitar el botón — ocultarlo completamente. Un `client_editor` no necesita saber que esa función existe.
---
## 5c — LibraryPage: UI ajustada para roles de cliente
### Ocultar "Nuevo proceso" para client_editor y client_viewer
```typescript
const canCreateProcess = user?.platformRole === 'platform_admin' || user?.platformRole === 'member'
{canCreateProcess && <CreateProcessButton />}
```
### Ocultar grupos/tags internos de InQuality para roles de cliente
Los `process_groups` y el sistema de grupos/tags son navegación interna de InQuality. Para `client_editor` y `client_viewer`, la Library debe mostrar directamente la lista de procesos sin el panel lateral de grupos.
```typescript
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
// Ocultar completamente el panel de grupos si es rol cliente
{!isClientRole && <ProcessGroupsPanel />}
```
Si el layout actual tiene los grupos integrados de forma que no se pueden ocultar sin romper la estructura, simplificar: mostrar todos los procesos sin agrupación para roles de cliente (la RLS ya garantiza que solo ven los suyos).
### Encabezado de bienvenida para roles de cliente
Opcional (versión wow): si `isClientRole`, mostrar el nombre de la organización como contexto:
```tsx
{isClientRole && user?.orgId && (
<p className="text-sm text-muted-foreground mb-4">
Procesos de <span className="font-medium">{orgName}</span>
</p>
)}
```
Para obtener `orgName`: hacer una query a `organizations` con `user.orgId`. Si ya hay un hook que lo cargue, usarlo. Si no, una query simple al cargar el componente.
---
## 5d — ReportPage: tab ROI por defecto para client_viewer
En `ReportPage`, la tab activa inicial debe ser `roi` cuando el usuario es `client_viewer`:
```typescript
const { user } = useAuth()
const defaultTab = user?.platformRole === 'client_viewer' ? 'roi' : 'actual'
const [activeTab, setActiveTab] = useState(defaultTab)
```
Las otras tabs (Actual, Automatizado) permanecen visibles — `client_viewer` puede verlas pero las actividades son de solo lectura (esto ya está garantizado por RLS: no puede hacer UPDATE/INSERT). No es necesario bloquear la UI explícitamente en esta etapa; la RLS rechazará cualquier intento de escritura.
Si el componente de tab activa ya es controlado desde el router (query param o similar), adaptar según el código real.
---
## Tests nuevos
```typescript
// tests/features/routing/role-guards.test.ts
// Con vitest + mocks del AuthContext:
// 1. client_viewer en /workspace/$id → redirect a /report/$id
// 2. client_editor en /workspace/$id → NO redirige (permanece)
// 3. client_editor en /import → redirect a /
// 4. platform_admin en /import → NO redirige
// tests/features/library/library-ui.test.ts
// 5. client_editor: "Nuevo proceso" no renderiza
// 6. client_editor: grupos panel no renderiza
// 7. platform_admin: "Nuevo proceso" renderiza
// 8. platform_admin: grupos panel renderiza
// tests/features/report/report-default-tab.test.ts
// 9. client_viewer: tab inicial = 'roi'
// 10. platform_admin: tab inicial = 'actual' (o cualquier otro default actual)
```
---
## Criterios de validación
- [ ] `client_viewer` que navega a `/workspace/:id` → redirige a `/report/:id`
- [ ] `client_editor` puede entrar al workspace normalmente
- [ ] Botón "Importar BPMN" no visible para `client_editor` ni `client_viewer`
- [ ] Botón "Nuevo proceso" no visible para `client_editor` ni `client_viewer`
- [ ] Panel de grupos no visible para `client_editor` ni `client_viewer`
- [ ] `ReportPage` con `client_viewer` abre en tab ROI
- [ ] `ReportPage` con `platform_admin` abre en la tab que ya abre actualmente (no cambiar comportamiento existente)
- [ ] `npm run test` → 558+ tests verdes (más los nuevos ~10)
- [ ] `npm run build` limpio
- [ ] **Validación visual del director antes del OK formal** (ver checklist abajo)
---
## Checklist de validación visual (para el director)
Para validar esta etapa necesitás idealmente dos sesiones: una como `platform_admin` y una como `client_editor`. Si aún no hay usuario cliente invitado, la validación puede hacerse manualmente editando el `platform_role` en Supabase Studio:
```sql
-- Cambiar temporalmente para prueba (revertir después)
UPDATE public.users SET platform_role = 'client_editor' WHERE id = '<tu-id>';
-- Después de probar:
UPDATE public.users SET platform_role = 'platform_admin' WHERE id = '<tu-id>';
```
Con rol `client_editor`:
- [ ] Library: no aparece "Nuevo proceso", no aparece el panel de grupos
- [ ] Workspace: no aparece el botón/opción de importar BPMN
- [ ] Workspace: todo lo demás funciona (editar actividades, simular, ver reporte)
Con rol `client_viewer` (mismo mecanismo de UPDATE temporal):
- [ ] Intentar navegar a `/workspace/$id` → redirige al reporte
- [ ] Reporte abre directo en tab ROI
---
## NO modificar
- Panel de admin (`/admin/*`)
- Migraciones (001-015)
- Edge Functions
- Lógica de simulación, dominio, cálculo de ROI, PDF, CSV
- `AppHeader` más allá de lo estrictamente necesario

View File

@@ -0,0 +1,169 @@
# Etapa 6 — Hotfix: flujo de invitación resiliente a email scanners
**Sprint:** 7 (hotfix post-cierre)
**Fecha:** 2026-07-07
**Alcance:** nueva ruta `/auth/confirm` + componente `ConfirmInvitePage`. Sin tocar migraciones, Edge Functions, panel admin, ni lógica de simulación.
---
## Problema
Gmail y Outlook pre-escanean los links del email de invitación antes de mostrárselos al usuario. El link generado por `inviteUserByEmail` apunta directamente a `/auth/v1/verify` en Supabase — un endpoint que consume el token de un solo uso al ser fetched. Los scanners lo fetchean → token consumido → "Not Found" cuando el usuario hace click.
---
## Solución
Cambiar el template del email de invitación para que el link apunte a una página de la app (`/auth/confirm`) en lugar del endpoint de Supabase. La página muestra un botón de confirmación. El token se consume **solo cuando el usuario hace click en el botón** — no cuando el scanner pre-fetchea la URL.
---
## Cambio en Supabase Dashboard (Marcos lo hace manualmente — NO es tarea de Claude Code)
Dashboard → Authentication → Email Templates → "Invite User"
Buscar la línea con `{{ .ConfirmationURL }}` en el template HTML y reemplazar el href del botón/link por:
```
https://inq-roi.inqualityhq.com/auth/confirm?token_hash={{ .TokenHash }}&type=invite
```
Guardar. Este cambio no requiere redeploy.
---
## Cambios en el código
**ANTES de empezar:** leer `src/router.tsx` y `src/features/auth/LoginPage.tsx` para entender el patrón de rutas públicas y el estilo visual existente.
### Nueva ruta `/auth/confirm` en `src/router.tsx`
Ruta pública (no requiere sesión activa). Misma estructura que `/login` y `/reset-password` ya existentes.
```typescript
// Ruta pública — sin guard de auth
{
path: '/auth/confirm',
component: ConfirmInvitePage,
}
```
### Nuevo componente `src/features/auth/ConfirmInvitePage.tsx`
**Leer los query params** (TanStack Router usa `useSearch` o el hook equivalente):
- `token_hash` — el hash del token de Supabase
- `type` — debe ser `'invite'`
**Estados del componente:**
- `idle` — muestra la pantalla de bienvenida con botón (estado inicial)
- `loading` — botón con spinner + disabled, mientras se llama a Supabase
- `error` — muestra mensaje de error + link a `/login`
- `success` — no se muestra (navega inmediatamente a `/reset-password`)
**Comportamiento:**
```typescript
// Al montar: NO llamar a verifyOtp automáticamente — esperar click del usuario
// Al hacer click en el botón:
const { error } = await supabase.auth.verifyOtp({
type: 'invite',
token_hash: tokenHash,
})
if (error) {
setStatus('error')
setErrorMessage(error.message)
} else {
// Sesión activa — navegar a reset-password para que el usuario setee su contraseña
navigate({ to: '/reset-password' })
}
```
**UI en estado `idle`:**
```
[Logo InQuality]
Bienvenido a InQ ROI
Tu consultor te ha dado acceso a la plataforma.
Hacé click para confirmar tu acceso y establecer tu contraseña.
[ Confirmar acceso ]
```
**UI en estado `error`:**
```
[Logo InQuality]
El link de invitación no es válido o ya fue utilizado.
Podría haber expirado (duración: 24 horas) o el link
fue abierto más de una vez.
Contactá a tu consultor InQuality para recibir una nueva invitación.
[ Ir al login ]
```
**Validación de parámetros:** si `token_hash` está vacío o falta `type=invite`, mostrar directamente el estado de error (sin intentar verificar).
**Estilo:** misma estructura visual que `LoginPage` — centrado en pantalla, card con sombra, logo de InQ en la parte superior. Usar los componentes shadcn/ui existentes (`Button`, `Card` o equivalente). No introducir librerías nuevas.
### `src/features/auth/ResetPasswordPage.tsx` — verificar comportamiento
Después de `verifyOtp({ type: 'invite' })`, Supabase dispara `SIGNED_IN` en `onAuthStateChange` (no `PASSWORD_RECOVERY`). El navigate a `/reset-password` lo hace `ConfirmInvitePage` explícitamente — no depende del evento de auth.
Verificar que `ResetPasswordPage` funciona cuando ya hay una sesión activa (sin necesidad de `PASSWORD_RECOVERY`). La página llama a `supabase.auth.updateUser({ password })` que solo requiere sesión activa — no un evento específico. Si ya funciona así, no hay cambios necesarios.
---
## Tests nuevos
```typescript
// tests/features/auth/confirm-invite.test.tsx
// 1. ConfirmInvitePage renderiza el botón sin llamar a verifyOtp al montar
// 2. ConfirmInvitePage con token_hash vacío muestra estado de error directamente
// 3. ConfirmInvitePage llama supabase.auth.verifyOtp solo al hacer click en el botón
// 4. ConfirmInvitePage navega a /reset-password cuando verifyOtp tiene éxito
// 5. ConfirmInvitePage muestra mensaje de error cuando verifyOtp falla
```
---
## Flujo completo esperado post-fix
1. Admin invita `usuario@cliente.com` desde el panel `/admin/users`
2. Edge Function `invite-user` crea el usuario en Supabase Auth y envía el email
3. Email llega con link: `https://inq-roi.inqualityhq.com/auth/confirm?token_hash=xxx&type=invite`
4. Gmail/Outlook escanea la URL → ve la página de bienvenida de la app → **no consume el token**
5. Usuario abre el email → hace click en el link → ve la pantalla de bienvenida con el botón
6. Usuario hace click en "Confirmar acceso" → la app llama a `verifyOtp` → sesión activa
7. App navega a `/reset-password` → usuario setea su contraseña
8. Usuario puede acceder a la app con email + contraseña
---
## Criterios de validación
- [ ] Abrir el link de invite directamente en browser → muestra la pantalla de bienvenida con botón (NO redirige ni consume el token automáticamente)
- [ ] Hacer click en "Confirmar acceso" → el token se consume, sesión activa, navega a `/reset-password`
- [ ] En `/reset-password`, el usuario puede setear su contraseña
- [ ] Después de setear contraseña, puede hacer logout y volver a entrar con email + contraseña
- [ ] Link con `token_hash` vacío o inválido → muestra error con link a `/login`
- [ ] `npm run test` → tests verdes (más los nuevos ~5)
- [ ] `npm run build` limpio
- [ ] **Validación visual del director antes del OK formal**
---
## NO modificar
- Edge Functions (`supabase/functions/`)
- Migraciones (001-015)
- Panel de admin (`/admin/*`)
- `WorkspacePage`, `ReportPage`, `LibraryPage`
- Lógica de simulación, dominio, ROI, PDF, CSV
- `AppHeader`

View File

@@ -0,0 +1,133 @@
# Etapa 7B — Botón "volver" contextual en workspace
**Sprint:** 7
**Fecha:** 2026-07-07
**Alcance:** workspace header únicamente. Sin tocar LibraryPage, AuthContext, migraciones, ni lógica de simulación.
---
## Problema
Cuando un admin (platform_admin/member) abre el workspace de un proceso que pertenece a un cliente (org no-provider), no hay forma de volver a la vista de los procesos de ese cliente. El ícono de casa (🏠) lleva a la biblioteca general, pero pierde el contexto de qué cliente se estaba revisando.
El mismo problema aplica a client_editor: al entrar al workspace, no hay un back button visible.
---
## Comportamiento esperado
### Para admin (platform_admin / member)
Si el proceso tiene `org_id` asignado (proceso de un cliente):
```
← Solar Banco [nombre del proceso] [botones de acción]
```
El botón "← {org.name}" navega a `/library?org={org_id}`.
Si el proceso NO tiene `org_id` (proceso sin cliente asignado):
```
← Biblioteca [nombre del proceso] [botones de acción]
```
Navega a `/library`.
### Para client_editor / client_viewer
```
← Procesos [nombre del proceso] [botones de acción]
```
Navega a `/library`. (Para estos roles, `/library` ya muestra solo los procesos de su org.)
---
## Cambios en código
**ANTES de empezar:** leer el componente del header del workspace para entender la estructura actual del breadcrumb/navegación. También leer cómo el store o el componente accede a la información del proceso actual (nombre, org_id). Verificar si la información de `org_id` y `org_name` está disponible en el estado del proceso cargado.
### 1. Library route — soporte para query param `?org=<uuid>`
En `LibraryPage.tsx` (o el router de la biblioteca), agregar soporte para el query param `org`:
- Leer `org` de los query params con TanStack Router (`useSearch`)
- Si `org` está presente: al cargar la biblioteca, hacer scroll automático al grupo/cliente cuyo `org_id` coincide, o resaltarlo visualmente
- Si `org` no está presente: comportamiento actual sin cambios
**No es necesario filtrar la lista** — mostrar todos los clientes como siempre, pero posicionar la vista en el cliente relevante. Esto preserva el contexto sin ocultar información.
El scroll puede ser simple: `document.getElementById('org-{org_id}')?.scrollIntoView({ behavior: 'smooth', block: 'start' })` con un `useEffect` que se dispara cuando `org` está en los params y la lista cargó.
### 2. Workspace header — back button contextual
En el componente del workspace header (WorkspaceHeader, WorkspacePage, o donde esté el breadcrumb):
Agregar antes del separador `|` y del nombre del proceso un botón de navegación contextual:
```typescript
// Determinar destino del back button
const backHref = process.orgId
? `/library?org=${process.orgId}`
: '/library'
const backLabel = process.orgName // si está disponible en el store
?? (isClientRole ? 'Procesos' : 'Biblioteca')
```
**UI:** botón ghost pequeño con ícono de flecha izquierda (`ArrowLeft` de Lucide) + texto. Consistente con el estilo del header actual. Ejemplo:
```
[←] Solar Banco | Conciliaciones ATM [editable] • ...
```
Si `process.orgName` no está disponible en el store actual, hay dos opciones:
a) Hacer un fetch adicional de la organización (simple: `supabase.from('organizations').select('name').eq('id', orgId).single()`) al cargar el workspace
b) O usar solo el botón sin nombre ("← Volver") y el admin sabrá dónde lo lleva
**Preferir opción (a)** si el costo de implementación es bajo (un SELECT adicional en el store de workspace). Si es complejo, usar (b) y registrar como mejora en BACKLOG.md.
---
## Tests
```typescript
// Mínimo:
// 1. Back button muestra texto correcto para admin con proceso de cliente
// (mock: process.orgId = 'some-uuid', process.orgName = 'Solar Banco')
// → expect button text to contain 'Solar Banco' o 'Volver'
// → expect link href to contain '/library?org=some-uuid'
// 2. Back button muestra "Biblioteca" para admin con proceso sin org
// (mock: process.orgId = null)
// → expect href = '/library'
// 3. Back button muestra "Procesos" para client_editor
// (mock: isClientRole = true)
// → expect href = '/library'
```
---
## Criterios de validación
- [ ] Admin en workspace de proceso con cliente → ve "← [nombre del cliente]" en el header
- [ ] Click en ese botón → navega a `/library?org=<uuid>` → biblioteca hace scroll al cliente correspondiente
- [ ] Admin en workspace de proceso sin cliente asignado → ve "← Biblioteca"
- [ ] client_editor en workspace → ve "← Procesos"
- [ ] El botón no aparece más de una vez, no rompe el layout del header
- [ ] `npm run test` → tests verdes
- [ ] `npm run build` limpio
- [ ] **Validación visual del director antes del OK formal**
---
## NO modificar
- Edge Functions
- Migraciones
- Lógica de simulación, ROI, PDF, CSV
- `ConfirmInvitePage`, `AdminPage`, `ReportPage`
- `AuthContext` (cubierto en Etapa 7)

View File

@@ -0,0 +1,176 @@
# Etapa 7 — Fix: race condition de roles en LibraryPage + simulations UPDATE policy
**Sprint:** 7
**Fecha:** 2026-07-07
**Alcance:** AuthContext (loading state), LibraryPage (guard), nueva migración SQL (`simulations_update`), campo client read-only para client_editor.
---
## Contexto del bug
`msebem@gmail.com` tiene `platform_role = 'client_editor'` confirmado en DB.
Sin embargo al cargar LibraryPage muestra elementos que solo debería ver InQuality:
- Botón "Importar BPMN"
- Panel de grupos ("Clientes")
- Ícono de configuración
Al navegar a otra ruta y volver, el comportamiento es correcto (elementos ocultos).
---
## Causa raíz confirmada — DOS problemas independientes
### Problema A: race condition de roles en LibraryPage
**Flujo actual:**
1. Login → `onAuthStateChange` SIGNED_IN → `buildAuthUser(supabaseUser)``platformRole: 'guest'` (heurística email; `msebem@gmail.com` no es `@inquality.com.py`)
2. `guest``isClientRole = false` → LibraryPage renderiza con todos los elementos visibles
3. `syncProfileFromDB` completa → `platformRole: 'client_editor'` → store actualizado
4. Navegar y volver → re-render correcto
La UI "miente" durante la ventana entre login y sync de DB. El fix es no renderizar contenido rol-dependiente hasta que `syncProfileFromDB` haya completado al menos una vez en la sesión.
### Problema B: política `simulations_update` inexistente
En `migration 015_client_roles_and_rls.sql` existe `simulations_insert` pero NO existe `simulations_update`.
Cuando "Guardar configuración" intenta hacer UPDATE sobre una simulación ya existente → PostgreSQL no encuentra política → operación bloqueada silenciosamente. El usuario no recibe error explícito pero los cambios no se persisten.
---
## Cambios requeridos
**ANTES de empezar:** leer `src/auth/AuthContext.tsx` (o el archivo que gestiona `onAuthStateChange` y llama a `syncProfileFromDB`) y `src/features/library/LibraryPage.tsx` para entender el estado actual. Leer también `src/features/workspace/WorkspacePage.tsx` para detectar si tiene el mismo problema.
---
### Fix A1: agregar `isProfileLoaded` al AuthContext
En el contexto de autenticación (el que expone `user`, maneja `onAuthStateChange`, llama a `syncProfileFromDB`):
Agregar un estado `isProfileLoaded: boolean` (inicialmente `false`).
Lógica:
- Se pone en `false` cuando empieza el proceso de login (SIGNED_IN event)
- Se pone en `true` después de que `syncProfileFromDB` completa (tanto si retorna datos como si retorna null)
- Cuando el evento es `SIGNED_OUT`, volver a `false`
- Cuando el token se refresca (`TOKEN_REFRESHED`) o el usuario ya estaba logueado al cargar la app, también completar el sync antes de poner `true`
Exponer `isProfileLoaded` en el contexto junto a `user`.
**Importante:** `isProfileLoaded: true` con `user: null` es un estado válido (usuario no logueado, pero ya se verificó). `isProfileLoaded: false` con `user: null` es "todavía verificando".
---
### Fix A2: guard en LibraryPage
**ANTES de empezar este fix:** leer el archivo completo de LibraryPage para entender dónde se usa `isClientRole` y qué elementos condicionan.
En LibraryPage, antes de renderizar contenido rol-dependiente (import button, groups panel, settings gear, y cualquier sección condicionada por `isClientRole`), verificar `isProfileLoaded`.
**Si `!isProfileLoaded`:** mostrar un estado de carga mínimo. No es necesario un skeleton complejo — basta con el mismo spinner o skeleton que ya usa LibraryPage en su estado de carga (`isLoading && groups.length === 0`). Reutilizar ese patrón si existe.
**Si `isProfileLoaded`:** renderizar normalmente, el `isClientRole` ahora tiene el valor correcto.
La idea es que el usuario vea un spinner breve (< 200ms típicamente) en lugar de una UI incompleta que luego cambia. Preferible UX.
---
### Fix A3: campo "cliente" read-only para client_editor (en WorkspacePage / GlobalPanel)
**ANTES de empezar:** leer el componente que renderiza el campo "cliente" (texto libre) en el sidebar o panel global del workspace. Puede estar en `WorkspacePage.tsx`, `GlobalPanel.tsx`, o similar.
Para `isClientRole = true`: mostrar el nombre del cliente como texto estático (no como input editable). El campo sigue visible pero no editable. No es necesario guardarlo o enviar cambios al hacer click en él.
Para InQuality (`!isClientRole`): comportamiento actual sin cambios.
---
### Fix B: nueva migración — `simulations_update` policy
Crear el archivo de migración con el número siguiente a 015 (verificar qué existe en `supabase/migrations/` y usar el siguiente).
Nombre: `016_add_simulations_update_policy.sql`
```sql
-- Fix: faltaba política UPDATE en simulations.
-- simulations_insert existe en 015 pero simulations_update no fue creada.
-- Sin esta policy, client_editor no puede guardar configuración sobre simulaciones existentes.
CREATE POLICY "simulations_update" ON public.simulations
FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id)
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
);
```
**Verificar antes de crear:** que no exista ya una `simulations_update` policy en la DB (puede haberse creado manualmente). Si ya existe, no crearla (o usar `DROP POLICY IF EXISTS` + recrear para idempotencia).
---
### Fix B2: verificar y corregir rename de proceso para client_editor
**ANTES de empezar:** en WorkspacePage (o donde se maneja el rename del proceso), encontrar la lógica que guarda el nombre cuando el usuario lo edita.
La política `update_processes` en migration 015 **ya permite** `client_editor AND org_id = current_org()`. Si el rename no funciona, el problema es frontend (el input no llama al save, o el save usa el método incorrecto).
Verificar:
1. ¿El input de nombre del proceso llama a la función de save en `onBlur` o `onEnter`?
2. ¿Esa función usa `supabase.from('processes').update()` con el id correcto?
3. Si ambos están bien pero sigue fallando → verificar en Supabase SQL Editor que `UPDATE public.processes SET name = 'test' WHERE id = '<process_id>'` funcione como el usuario client_editor (hint: no se puede hacer directamente en SQL Editor como el usuario — verificar la condición de la policy manualmente)
Si es un bug frontend (el save no se dispara), arreglarlo.
---
## Tests nuevos
```typescript
// Mínimo requerido:
// 1. LibraryPage no renderiza elements rol-dependientes hasta isProfileLoaded = true
// (simular: isProfileLoaded: false → assert import button no visible, groups panel no visible)
// 2. LibraryPage renderiza correctamente para client_editor una vez isProfileLoaded = true
// (simular: isProfileLoaded: true, platformRole: 'client_editor' → assert import button no visible, groups panel no visible)
// 3. LibraryPage renderiza correctamente para platform_admin una vez isProfileLoaded = true
// (simular: isProfileLoaded: true, platformRole: 'platform_admin' → assert import button visible)
```
---
## Criterios de validación
- [ ] Loguear como `msebem@gmail.com` → LibraryPage NO muestra Import BPMN ni groups panel en ningún momento (ni durante la carga inicial)
- [ ] "Guardar configuración" en workspace funciona y persiste los cambios (no silent fail)
- [ ] El campo "cliente" en el sidebar del workspace es read-only para `msebem@gmail.com`
- [ ] Un usuario InQuality (platform_admin/member) sigue viendo todos los elementos sin regresión
- [ ] `npm run test` → tests verdes (incluidos los 3 nuevos mínimos)
- [ ] `npm run build` limpio
- [ ] **Validación visual del director antes del OK formal**
---
## NO modificar
- Edge Functions (`supabase/functions/`)
- Migraciones 001-015 (no editar, solo crear 016 nueva)
- Panel de admin (`/admin/*`)
- Motor de simulación, dominio, ROI, PDF, CSV
- `ConfirmInvitePage` (Etapa 6)
- `ReportPage`
- Lógica de cálculo en `domain/`
---
## Flujo esperado post-fix
1. `msebem@gmail.com` loguea
2. LibraryPage muestra spinner breve (~200ms)
3. `syncProfileFromDB` completa → `client_editor`
4. LibraryPage renderiza: lista flat de procesos de Solar Banco, sin import button, sin groups panel, sin settings gear
5. Entra al workspace de su proceso
6. Edita parámetros de actividades y configuración global
7. Hace click en "Guardar configuración" → persiste correctamente
8. Puede ver reporte y exportar PDF

610
sprints/sprint-8/BRIEF.md Normal file
View File

@@ -0,0 +1,610 @@
# Sprint 8 BRIEF — InQ ROI
> **Estado:** borrador pendiente aprobación del director
> **Abierto:** 2026-07-07
> **Sprint anterior:** Sprint 7 — multi-tenancy (organizations, 4 roles, Edge Functions, Admin UI)
> **Versión base:** v0.8.0 (según SemVer: Sprint 8 arranca en v0.8.0)
---
## Contexto y dirección estratégica
Sprint 7 entregó la fundación de multi-tenancy. El producto puede invitar clientes, asignarles roles, y que vean solo sus procesos. Ahora hay tres gaps que bloquean el uso real con clientes:
1. **El modelo de "cliente" en la UI es inconsistente**`process_groups` (agrupador visual), `clientName` (texto libre), y `org_id` (FK real) son tres conceptos que intentan modelar lo mismo sin coordinación. Un admin que invita a Solar Banco no puede ver sus procesos agrupados como "Solar Banco" en la biblioteca.
2. **client_editor no puede subir su propio BPMN** — si Solar Banco tiene un proceso que quiere modelar, necesita pedirle a InQuality que lo importe. Esto no escala.
3. **No hay versión visible ni changelog** — el producto está en producción sin que nadie sepa qué versión está corriendo ni qué cambió.
**Objetivo del sprint:** consolidar el modelo de cliente, habilitar autonomía de client_editor para BPMN, y establecer el ciclo profesional de versiones. Sprint relativamente arquitectónico — ~40% DB/repos, ~40% UI/features, ~20% infra/deuda.
---
## Prioridades del sprint
### Bloque A — Comprometidas (must have)
| # | Feature | Tipo |
|---|---|---|
| Etapa 0 | Versionado: Conventional Commits + CHANGELOG + footer versión | Infra |
| Etapa 1 | DB: tabla `areas`, `area_id` en `processes`, drop `process_groups` y `clientName` | DB/migración |
| Etapa 2 | Repositorios: `area-repo.ts`, actualizar `process-repo.ts`, tipos y stores | Backend |
| Etapa 3 | LibraryPage: agrupar por org → área + ocultar Settings para roles cliente | UI |
| Etapa 4 | Workspace sidebar: selector de área reemplaza `clientName` + Admin CRUD de áreas | UI + Admin |
| Etapa 5 | client_editor BPMN: política INSERT + UPDATE bpmn_xml, botón importar visible | DB/UI |
### Bloque B — Si hay tiempo (prioridad 2)
| # | Feature | Tipo |
|---|---|---|
| Etapa 6 | Deuda técnica: guards → `beforeLoad` + hard delete + email en admin | Refactor |
| Etapa 7 | Página `/help` con documentación de uso para todos los roles | UI |
**Nota del director al Bloque B:** si las Etapas 0-5 consumen el sprint, Etapas 6-7 pasan a BACKLOG. No forzar a sprint siguiente sin capacidad real.
---
## Reglas del sprint (no negociables)
- `simulador-web/CLAUDE.md` tiene la guía completa de stack, reglas de marca, y la nueva sección de versionado (agregada como Etapa 0)
- **Validación visual mandatoria** en cada etapa que modifique UI antes del OK formal
- **Brief como contrato:** ninguna etapa arranca sin `ETAPA_X_PROMPT.md` aprobado
- **git diff antes de reportar:** incluir en cada reporte la lista de archivos modificados vs. los autorizados
- **Iniciativa agéntica clasificada** — Nivel 3 (marca, arquitectura, datos, TECH_DEBT.md) requiere consulta antes de ejecutar
- **`isProfileLoaded` como prerequisito** en cualquier componente nuevo condicionado por rol (aprendizaje Sprint 7)
- **JOIN vs fetch separado** — cuando un componente necesita datos relacionales para display, preferir JOIN en repo (aprendizaje Sprint 7)
---
## Etapa 0: Versionado profesional
**Objetivo:** establecer el ciclo de versiones antes de abrir cualquier feature nueva.
### Alcance
**1. Actualizar `simulador-web/CLAUDE.md`** — agregar sección "Gestión de versiones y commits" con:
```
Conventional Commits:
feat(scope): descripción
fix(scope): descripción
refactor(scope): descripción
test(scope): descripción
docs(scope): descripción
chore(scope): descripción
scopes comunes: auth, workspace, library, report, pdf, admin, db, e2e, help
SemVer:
MAJOR: cambio incompatible (nuevo modelo de datos, cambio de rol fundamental)
MINOR: feature nueva visible al usuario
PATCH: fix de bug o mejora de UX sin feature nueva
Sprint 8 arranca en v0.8.0
```
**2. Crear `CHANGELOG.md`** en raíz de `simulador-web/`, formato Keep a Changelog:
```markdown
# Changelog
All notable changes to InQ ROI will be documented in this file.
## [Unreleased]
## [0.7.0] - 2026-07-07
### Added
- Multi-tenancy: tabla organizations, 4 roles de plataforma
- Edge Functions invite-user y revoke-user
- Panel de administración /admin (Organizations, Users)
- Login email/password + página /reset-password
- back button contextual en workspace (orgName via JOIN en getById)
- Política simulations_update (fix para client_editor)
### Fixed
- Race condition AuthContext: isProfileLoaded guard
- syncProfileFromDB leyendo platform_role desde DB (no inferido por email)
- normalizeGroupLabel: limpia artículos iniciales de labels de grupos
## [0.6.0] - 2026-06-24
...
```
Completar el historial con los sprints anteriores hasta v0.1.0 (MVP).
**3. Actualizar `package.json`**`"version": "0.8.0"` (actualmente tiene algún valor anterior, verificar).
**4. Footer con versión visible** — crear `src/components/AppFooter.tsx`:
- Muestra `InQ ROI v{version}` leyendo de `src/lib/version.ts` (que importa de `package.json`)
- Visible en todas las páginas (LibraryPage, ReportPage, al menos)
- Texto pequeño, discreto, esquina inferior
**5. Tag de cierre de sprint:** al finalizar Sprint 8, `git tag -a v0.8.0 -m "Release v0.8.0: Sprint 8"`.
### Criterios de validación
- [ ] `CHANGELOG.md` existe con historial desde v0.1.0 hasta v0.7.0
- [ ] `package.json` tiene `"version": "0.8.0"`
- [ ] Footer visible en LibraryPage mostrando "InQ ROI v0.8.0"
- [ ] Build sin errores
### NO entra
- Página de "qué hay de nuevo" o modal de release notes
- Versionado de procesos (FASE-2)
---
## Etapa 1: Migración DB — tabla `areas`
**Objetivo:** crear la infraestructura de datos para el modelo `organizations → areas → processes`.
### Contexto
Los tres conceptos de "cliente" actuales:
| Concepto | Tabla/Campo | Problema |
|---|---|---|
| Agrupador visual | `process_groups` | Nombre confuso, sin FK a organizations |
| Campo libre | `clientName` (text en processes) | Sin normalización, sin validación |
| Tenant real | `org_id` en processes | Correcto pero desconectado de la UI |
### Alcance — Migración 017
```sql
-- 1. Crear tabla areas
CREATE TABLE public.areas (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
org_id uuid REFERENCES public.organizations(id) ON DELETE CASCADE NOT NULL,
name text NOT NULL,
description text,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
-- 2. Agregar area_id en processes (reemplaza group_id)
ALTER TABLE public.processes ADD COLUMN area_id uuid REFERENCES public.areas(id) ON DELETE SET NULL;
-- 3. Eliminar client_name (texto libre) de processes
-- Verificar que clientName existe con su nombre exacto en la tabla
ALTER TABLE public.processes DROP COLUMN IF EXISTS client_name;
-- Nota: verificar nombre exacto antes de ejecutar (puede ser "client_name" o "clientName" en postgres)
-- 4. RLS para areas
ALTER TABLE public.areas ENABLE ROW LEVEL SECURITY;
CREATE POLICY "areas_select" ON public.areas
FOR SELECT USING (
-- InQuality ve todas; otros ven solo las de su org
public.is_inquality()
OR org_id = public.current_org()
);
CREATE POLICY "areas_insert" ON public.areas
FOR INSERT WITH CHECK (
-- Solo platform_admin puede crear áreas
public.current_platform_role() = 'platform_admin'
);
CREATE POLICY "areas_update" ON public.areas
FOR UPDATE USING (
public.current_platform_role() = 'platform_admin'
);
CREATE POLICY "areas_delete" ON public.areas
FOR DELETE USING (
public.current_platform_role() = 'platform_admin'
);
-- 5. Eliminar process_groups si existe
-- Primero verificar que processes.group_id existe y hacer SET NULL antes de drop
UPDATE public.processes SET area_id = NULL WHERE area_id IS NULL; -- no-op, sanity check
-- Si process_groups existe:
-- ALTER TABLE public.processes DROP COLUMN IF EXISTS group_id;
-- DROP TABLE IF EXISTS public.areas_old; -- nombre provisional
-- DROP TABLE IF EXISTS public.process_groups;
-- 6. Migración de datos: bulk-assign procesos sin org a InQuality
-- UPDATE public.processes SET org_id = '<inq_uuid>' WHERE org_id IS NULL;
-- (ejecutar con el UUID real de InQuality — buscar en SELECT id FROM organizations WHERE name = 'InQuality')
```
**Precaución importante:** antes de escribir los DROP/ALTER, auditar el schema real con:
```sql
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'processes' AND table_schema = 'public';
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_name LIKE '%group%';
```
Reportar nombres exactos antes de ejecutar cualquier DROP.
### Criterios de validación
- [ ] Tabla `areas` creada con RLS activa
- [ ] `area_id` en `processes` nullable con FK
- [ ] `client_name` (o equivalente) eliminado de `processes`
- [ ] `process_groups` eliminada (verificar si existe)
- [ ] `group_id` en `processes` eliminado (si existía)
- [ ] Bulk-assign: todos los procesos tienen `org_id NOT NULL` (o decision explícita sobre los NULL)
- [ ] Tests unitarios de RLS: platform_admin puede CRUD en areas, client_editor no puede
### NO entra
- CRUD en la UI (eso es Etapa 4)
- Cambios en los repositorios de TypeScript (eso es Etapa 2)
---
## Etapa 2: Repositorios y tipos
**Objetivo:** crear `area-repo.ts` y actualizar `process-repo.ts` y los tipos de dominio para reflejar el nuevo modelo.
### Alcance
**1. Actualizar `src/domain/types.ts`:**
```typescript
// Nuevo tipo
export interface Area {
id: string
orgId: string
name: string
description: string | null
createdAt: string
}
// Actualizar Process — eliminar clientName, agregar areaId y areaName
export interface Process {
// ... campos existentes ...
orgId: string | null
orgName?: string | null // poblado via JOIN, no se persiste
areaId: string | null // ← nuevo, reemplaza groupId
areaName?: string | null // ← nuevo, poblado via JOIN
// eliminar: clientName, groupId (si existían)
}
```
**2. Crear `src/persistence/supabase/area-repo.ts`:**
```typescript
export interface AreaRepo {
getByOrg(orgId: string): Promise<Area[]>
create(data: { orgId: string; name: string; description?: string }): Promise<Area>
update(id: string, data: { name?: string; description?: string }): Promise<Area>
delete(id: string): Promise<void>
}
```
**3. Actualizar `src/persistence/supabase/process-repo.ts`:**
- `fromRow()`: extraer `areaId` y `areaName` (via JOIN cuando el query lo incluya)
- `getById()`: JOIN con `areas` para traer `areaName` embebido (patrón ya establecido con `orgName`)
- `getAll()` / `getByOrg()`: incluir `area_id` en select, agregar optional JOIN para `areaName`
- `create()`: incluir `area_id` en el objeto de insert (si está presente)
- `updateEditable()`: incluir `area_id` en los campos actualizables
- Eliminar cualquier referencia a `clientName` o `groupId`
**4. Actualizar Zustand store** (`src/store/process-store.ts` o equivalente):
- Si el store tenía lógica de `clientName`, actualizarla
- Verificar que el tipo `Process` del store usa los nuevos campos
**5. Actualizar `src/persistence/supabase/settings-repo.ts`** (si mantenía `process_groups`):
- Identificar si hay lógica ligada a `process_groups` y eliminarla o migrarla
### Criterios de validación
- [ ] `Area` type definido en `domain/types.ts`
- [ ] `Process.areaId` y `Process.areaName` presentes; `clientName`/`groupId` eliminados
- [ ] `area-repo.ts` con CRUD completo
- [ ] `process-repo.ts` actualizado: fromRow, getById (con JOIN), create, updateEditable
- [ ] Build TypeScript sin errores
- [ ] Tests unitarios de `area-repo.ts`
### NO entra
- Cambios en componentes React (Etapas 3 y 4)
- Settings page todavía puede romper — se verá en Etapa 3
---
## Etapa 3: LibraryPage — agrupación org → área
**Objetivo:** reemplazar la agrupación actual por `clientName` con agrupación real por `org → área`.
### Contexto de la UI actual
LibraryPage muestra procesos agrupados por `clientName` (texto libre). Hay un panel de "Clientes" (renderizado desde `process_groups`). Para roles cliente, muestra lista plana. El ícono de Settings (⚙) lleva a la página de configuración de grupos — visible para todos los roles.
### Alcance
**1. Lógica de agrupación:**
- Para `platform_admin` (InQuality): agrupar por `orgName` → dentro de cada org, sub-agrupar por `areaName`
- Para `client_editor` / `client_viewer`: mostrar lista plana de sus procesos (sin agrupación — la org es implícita)
- Grupos sin área asignada: mostrar en sección "Sin área" dentro de la org correspondiente
**2. Estructura visual (admin view):**
```
┌─ Solar Banco ───────────────────────────────────────────┐
│ ┌─ Área: Operaciones ──────────────────────────────┐ │
│ │ [proceso A] [proceso B] │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌─ Área: Recursos Humanos ─────────────────────────┐ │
│ │ [proceso C] │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌─ Sin área ───────────────────────────────────────┐ │
│ │ [proceso D] │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌─ InQuality ─────────────────────────────────────────────┐
│ ... │
└─────────────────────────────────────────────────────────┘
```
**3. Eliminar el panel de "Clientes" (process_groups):**
- El componente o sección que renderizaba grupos desde `process_groups` se elimina
- Verificar qué componentes lo referencian
**4. Ocultar Settings para roles cliente:**
- El ícono ⚙ en LibraryPage se oculta para `client_editor` y `client_viewer`
- Agregar guard en la ruta `/settings` (o el path real): `client_editor` y `client_viewer` redirigen a `/library`
- Verificar si `/recursos` también debe ocultarse para clientes (reportar, no decidir unilateralmente)
**5. Criterio de wow:** al colapsar/expandir grupos, usar animación suave (Tailwind `transition-all`). Los grupos de org colapsan con click en el header.
### Criterios de validación
- [ ] Admin ve procesos agrupados por org → área (validación visual del director)
- [ ] client_editor ve lista plana de sus procesos (sin panel de grupos)
- [ ] ⚙ Settings no visible para client_editor y client_viewer
- [ ] Ruta `/settings` tiene guard de rol
- [ ] Animación de colapso funciona
- [ ] Build sin errores, tests existentes pasan
### NO entra
- CRUD de áreas (Etapa 4)
- Workspace sidebar (Etapa 4)
---
## Etapa 4: Workspace sidebar + Admin CRUD de áreas
**Objetivo:** (a) reemplazar el campo `clientName` libre en el workspace por un selector de área normalizado; (b) habilitar a admin para crear/editar/eliminar áreas desde `/admin`.
### 4A — Workspace sidebar
**Campo "Área" en el panel de configuración del proceso:**
- Reemplaza el campo de texto libre "Cliente" (`clientName`)
- Para `platform_admin`: dropdown de todas las áreas de la org del proceso (si tiene org asignada) + opción "Sin área". Si el proceso no tiene org, mostrar selector de org primero.
- Para `client_editor`: dropdown de áreas de su propia org (su org es implícita — no puede cambiarla)
- Para `client_viewer`: read-only, muestra nombre del área si existe
- Al cambiar área, actualizar `processStore` y hacer persist en Supabase vía `updateEditable()`
**Campo "Organización" en el workspace:**
- Para `platform_admin`: selector de org (dropdown de todas las orgs) — permite reasignar un proceso a otra org
- Para `client_editor`: read-only, muestra el nombre de su org
- Actualizar `org_id` en Supabase cuando el admin cambia la org
**Eliminar campo "Cliente" (clientName):**
- El campo de texto libre que existía en el sidebar se elimina completamente
### 4B — Admin CRUD de áreas
En `/admin/organizations/[orgId]` (o en la página de detalle de org que exista):
- Agregar sección "Áreas" con lista de áreas de la org
- CRUD: crear área (nombre + descripción opcional), renombrar, eliminar
- Al eliminar: si hay procesos con esa área, mostrar confirmación "X procesos quedarán sin área asignada"
- RLS garantiza que solo platform_admin puede hacer CRUD
**Estructura sugerida:**
```
/admin
/organizations
→ lista de orgs (ya existe)
/[orgId]
→ detalle de org: nombre, miembros, ÁREAS (nuevo)
```
Si `/admin/organizations/[orgId]` no existe, crear la página. Si ya existe (verificar), agregar la sección de Áreas.
### Criterios de validación
- [ ] Campo "Cliente" (texto libre) eliminado del workspace sidebar
- [ ] Selector de área funciona para platform_admin y client_editor (validación visual)
- [ ] Cambio de área persiste en Supabase (verificar Network tab)
- [ ] CRUD de áreas en admin funciona (crear, editar, eliminar con confirmación)
- [ ] Tests E2E: admin crea área, asigna proceso, client_editor ve sus procesos con área correcta
### NO entra
- Jerarquía de áreas (sub-áreas) — FASE-2
- Migrar todos los procesos existentes a áreas manualmente — eso se hace en bulk en la migración DB
---
## Etapa 5: client_editor — permisos de BPMN
**Objetivo:** habilitar a `client_editor` para importar nuevos procesos y actualizar el BPMN de procesos existentes de su org.
### Contexto
En Sprint 7 Etapa 5, `client_editor` fue bloqueado explícitamente de:
- Ver el botón "Importar BPMN" (redireccionado desde `/import`)
- Actualizar `bpmn_xml` vía `updateEditable()` (el campo no estaba en la lista de campos editables para ese rol)
Esta decisión fue provisional. Sprint 8 la revierte con las restricciones correctas.
### Alcance
**1. Migración 018 — política INSERT en `processes` para client_editor:**
```sql
-- Ampliar la política de INSERT para incluir client_editor
-- (auditar el nombre real de la política INSERT actual antes de DROP)
DROP POLICY IF EXISTS "processes_insert" ON public.processes;
CREATE POLICY "processes_insert" ON public.processes
FOR INSERT WITH CHECK (
-- platform_admin y member pueden insertar sin restricción de org
public.current_platform_role() IN ('platform_admin', 'member')
OR (
-- client_editor puede crear procesos solo en su propia org
public.current_platform_role() = 'client_editor'
AND org_id = public.current_org()
)
);
```
**2. Actualizar `updateEditable()` en `process-repo.ts`:**
- Incluir `bpmn_xml` en los campos actualizables cuando el caller tiene permisos
- Verificar que la política UPDATE existente ya permite que `client_editor` actualice `bpmn_xml` (debería estar cubierta por la política general de UPDATE que verifica org_id)
- Si no, agregar rama en la política UPDATE
**3. Botón "Importar BPMN" visible para client_editor:**
- En `ImportPage.tsx`: eliminar el guard que redirigía a `client_editor`
- `client_editor` accede a `/import`, selecciona BPMN, el proceso se crea con `org_id = current_org()`
- El `owner_id` se asigna como `auth.uid()` (el client_editor que importó)
**4. Restricciones que NO cambian:**
- `client_editor` no puede mover procesos entre orgs (no puede cambiar `org_id` a otra org)
- `client_editor` no puede cambiar `owner_id`
- `client_editor` no puede ver ni modificar procesos de otra org
### Criterios de validación
- [ ] Validar con el director usando cambio temporal de rol en Supabase Studio (técnica de Sprint 7)
- [ ] `client_editor` puede importar un BPMN nuevo → proceso creado con `org_id` correcto (verificar en Supabase Studio)
- [ ] `client_editor` puede abrir un proceso de su org y actualizar el BPMN (re-import)
- [ ] `client_editor` NO puede crear procesos con `org_id` de otra org (test E2E adversarial)
- [ ] Tests E2E: flujo completo import → workspace → update bpmn como client_editor
### NO entra
- client_editor con permisos en `resources` o en `admin` — no cambia
- Eliminar procesos (sigue siendo solo platform_admin)
---
## Etapa 6: Deuda técnica consolidada
**Objetivo:** resolver items de TECH_DEBT.md acumulados de Sprint 7. Bloque B — solo si hay tiempo.
### 6A — Guards de rol → `beforeLoad` (3 componentes)
Migrar de `useEffect` a `beforeLoad` de TanStack Router en:
- `AdminLayout.tsx`
- `WorkspacePage.tsx`
- `ImportPage.tsx`
Ejemplo de patrón correcto:
```typescript
// En el router (router.tsx o route file)
export const adminRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/admin',
beforeLoad: ({ context }) => {
if (!context.auth.isProfileLoaded) return // esperar
if (context.auth.user?.platformRole !== 'platform_admin') {
throw redirect({ to: '/library' })
}
},
})
```
**Restricción:** no cambiar el comportamiento de los guards, solo el mecanismo. Los tests E2E existentes deben seguir pasando.
### 6B — Hard delete de usuarios
Crear Edge Function `delete-user`:
- Llama a Supabase Auth Admin API: `supabase.auth.admin.deleteUser(userId)`
- Elimina registro de `public.users`
- Solo accesible para `platform_admin`
- Agregar botón "Eliminar permanentemente" en Admin → Users (con AlertDialog de confirmación doble: "Esta acción no puede deshacerse. El usuario perderá acceso permanentemente.")
- Diferencia respecto al soft delete (revoke-user): hard delete borra el auth record
### 6C — Email visible en Admin → Users
La tabla de usuarios en admin no muestra el email (que está en `auth.users`, no en `public.users`). Fix:
- En la Edge Function de listado de usuarios (o crear una nueva `list-users`), hacer join con `auth.users` via service_role
- Mostrar email en la tabla de Admin → Users
### Criterios de validación
- [ ] Guards migrados: no hay flash visual al navegar a ruta protegida (verificar con conexión lenta)
- [ ] Hard delete funciona y el usuario desaparece de la auth
- [ ] Email visible en Admin → Users
---
## Etapa 7: Página `/help`
**Objetivo:** documentación de uso del producto para todos los roles. Bloque B — solo si hay tiempo.
### Alcance
**Nueva ruta `/help`** accesible para todos los roles autenticados.
**Contenido mínimo (4 secciones):**
1. **Cómo parametrizar actividades**
- Tiempos de ejecución (tiempo nominal y AS-IS/TO-BE)
- Probabilidad de ejecución
- Recursos asignados y minutos de utilización
- Costo fijo por actividad
2. **Cómo simular y leer el reporte**
- Qué hace el botón "Simular"
- Indicador de simulación desactualizada
- Qué significa cada KPI del reporte
- Cómo leer el heatmap
- Columnas de la tabla comparativa (ahorro, eficiencia)
3. **Cómo exportar**
- PDF: qué incluye cada documento (Actual, Automatizado, ROI)
- CSV: estructura del archivo exportado
- Cuándo usar PDF vs. CSV
4. **Configuración del proceso (panel Global)**
- Campos del panel: nombre, descripción, volumen mensual, horas laborables
- Escenario automatizado: costo fijo TO-BE, inversión inicial, ROI
**UX:**
- Layout con sidebar de navegación a las 4 secciones
- Link a `/help` en el footer (junto a la versión)
- Criterio de wow: buscador simple de texto en la página
### Criterios de validación
- [ ] Las 4 secciones tienen contenido completo y preciso (validación visual del director)
- [ ] Link desde footer funciona
- [ ] Accesible para todos los roles
---
## Definiciones estables durante Sprint 8 (no reabrir)
- Stack tecnológico (sin cambios)
- Paleta cromática y sistema de componentes (sin cambios)
- Modelo de escenarios Modelo A (sin cambios)
- ROI: cálculos nominales simples (sin cambios)
- 4 roles de plataforma: `platform_admin`, `member`, `client_editor`, `client_viewer` (sin cambios)
---
## Convención de commits para Sprint 8
```
feat(areas): tabla areas + RLS en migración 017
feat(area-repo): CRUD de áreas por organización
feat(library): agrupación por org → área reemplaza clientName
feat(workspace): selector de área reemplaza campo clientName libre
feat(admin): CRUD de áreas en panel de organización
feat(client-editor): permisos BPMN insert + update vía migración 018
feat(help): página /help con 4 secciones de documentación de uso
fix(import): botón importar visible para client_editor
refactor(router): guards AdminLayout + WorkspacePage + ImportPage → beforeLoad
chore(version): v0.8.0, CHANGELOG.md, footer con versión
docs(changelog): etapa N sprint 8
```
---
## Archivos de referencia
| Archivo | Por qué leerlo antes |
|---|---|
| `simulador-web/CLAUDE.md` | Reglas de implementación, stack, marca, versionado |
| `simulador-web/STRATEGIC_DIRECTION.md` | Decisiones estratégicas para Sprint 8 (sección final) |
| `simulador-web/BACKLOG.md` | Item `[SPRINT-8]` con scope de org→areas |
| `simulador-web/TECH_DEBT.md` | Items guards + auth para Etapa 6 |
| `supabase/migrations/` | Auditar schema actual antes de escribir migraciones 017-018 |
| `src/persistence/supabase/process-repo.ts` | Entender fromRow y getById para extender con areaId |
| `src/features/library/LibraryPage.tsx` | Entender agrupación actual para reemplazarla |
| `src/features/workspace/WorkspacePage.tsx` | Entender sidebar actual para reemplazar clientName |
| `src/features/admin/` | Entender estructura de Admin UI para agregar CRUD de áreas |

View File

@@ -0,0 +1,267 @@
# Sprint 8 — Etapa 0: Versionado profesional
> **Tipo:** Infraestructura / Documentación
> **Rama:** main
> **Estimación:** 1-2 horas
> **Dependencias:** ninguna — es la primera etapa del sprint
---
## Objetivo
Establecer el ciclo profesional de versiones antes de abrir cualquier feature nueva:
1. `CHANGELOG.md` con historial completo desde v0.1.0
2. `package.json` actualizado a v0.8.0
3. `AppFooter` visible en la app con la versión actual
4. `simulador-web/CLAUDE.md` actualizado con la sección de versionado
---
## Contexto
El producto está en producción en https://inq-roi.inqualityhq.com y https://process-cost-platform.pages.dev. No existe `CHANGELOG.md`. La versión en `package.json` tiene algún valor anterior (verificar).
**`AppFooter.tsx` ya existe** en `src/components/AppFooter.tsx` con el texto:
```
InQ ROI · Powered by InQuality · v0.1.0 · Hecho desde Paraguay 🇵🇾
```
La versión está hardcodeada como `v0.1.0`. Esta etapa la hace dinámica y agrega el footer a LibraryPage (actualmente solo está en ReportPage e ImportPage).
El sprint adopta Conventional Commits a partir de esta etapa. Todos los commits de Sprint 8 deben seguir el formato especificado en `simulador-web/CLAUDE.md` (sección "Gestión de versiones y commits").
---
## Alcance estricto
### 1. Verificar versión actual en `package.json`
Leer `package.json` y reportar el valor actual de `"version"`. Luego actualizarlo a `"0.8.0"`.
### 2. Crear `src/lib/version.ts`
```typescript
// Lee la versión desde package.json en tiempo de build (Vite lo expone vía import.meta.env)
// Alternativa simple: constante string que se actualiza manualmente con cada release
export const APP_VERSION = '0.8.0'
```
Si Vite ya expone `import.meta.env.VITE_APP_VERSION` o similar, usar ese mecanismo. Si no, usar la constante string — es más simple y suficiente para el caso de uso.
### 3. Actualizar `src/components/AppFooter.tsx`
El componente **ya existe** con versión hardcodeada `v0.1.0`. Solo cambiar la versión hardcodeada por la constante de `version.ts`. Mantener el resto del texto y estilos exactamente igual.
```tsx
import { APP_VERSION } from '@/lib/version'
export function AppFooter() {
return (
<footer className="mt-12 py-4 border-t border-slate-100 text-center">
<p className="text-xs text-slate-400">
InQ ROI · Powered by InQuality · v{APP_VERSION} · Hecho desde Paraguay 🇵🇾
</p>
</footer>
)
}
```
**NO cambiar** el texto, los estilos, ni el formato — solo reemplazar `v0.1.0` por `v{APP_VERSION}`.
### 4. Agregar `AppFooter` a LibraryPage
El componente ya está integrado en `ReportPage.tsx` e `ImportPage.tsx`. Falta en LibraryPage.
- `LibraryPage.tsx` — importar `AppFooter` y agregarlo antes del cierre del contenedor principal
**NO tocar** ReportPage ni ImportPage — ya tienen `<AppFooter />`.
**NO modificar** `AppHeader.tsx`.
### 5. Crear `CHANGELOG.md`
Ubicación: `simulador-web/CHANGELOG.md` (raíz del proyecto, mismo nivel que `package.json`).
Formato: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
```markdown
# Changelog
All notable changes to InQ ROI will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.8.0] - 2026-07-07
### Added
- CHANGELOG.md con historial de versiones
- AppFooter con versión visible en todas las páginas
- Conventional Commits adoptados como estándar de commits
## [0.7.0] - 2026-07-07
### Added
- Multi-tenancy: tabla `organizations`, 4 roles de plataforma (platform_admin, member, client_editor, client_viewer)
- Edge Functions `invite-user` y `revoke-user` para gestión de acceso
- Panel de administración `/admin` con gestión de Organizations y Users
- Login email/password y página `/reset-password` para flujo de invitación
- Back button contextual en workspace (muestra nombre del cliente via JOIN en getById)
- Política RLS `simulations_update` para que client_editor pueda guardar simulaciones
### Fixed
- Race condition en AuthContext: `isProfileLoaded` como guard antes de renderizar contenido rol-dependiente
- `syncProfileFromDB` ahora lee `platform_role` y `org_id` desde DB (no inferido por dominio de email)
- `normalizeGroupLabel()`: limpia artículos iniciales de labels de grupos con datos corruptos
## [0.6.0] - 2026-06-24
### Added
- Logo InQuality en header de PDFs (isotipo, páginas 2+, fallback a texto)
- Panel de análisis de sensibilidad en tab ROI: sliders de volumen e inversión, recálculo en tiempo real
- Doble línea en CumulativeSavingsChart (base gris dashed + ajustada naranja con área sombreada)
- `whitespace-nowrap` en MetricCell de columna Eficiencia
### Fixed
- `ProfileSheet.getAccessToken()` leía clave inexistente (`'supabase.auth.token'`); el guardado de perfil nunca llegaba a Supabase desde Sprint 4. Fix: calcular `sb-${projectRef}-auth-token`
## [0.5.0] - 2026-06-23
### Added
- Fix crítico: workspace loading — race condition auth/processId resuelto con `isProfileLoaded`
- Controles de zoom BPMN: fit-viewport con centering correcto, botones zoom-in/zoom-out/fit
- Heatmap con gradiente continuo de 4 stops + toggle percentile/min-max persistido
- "Ver procesos por recurso" en catálogo de recursos
- Campo "empresa" en perfil de usuario
- Columna "Eficiencia de automatización" en ComparisonTable (+ columna "Ahorro esperado")
### Fixed
- Flakiness en botón "Guardar cambios": debounce + Web Locks en process-store
- Specs E2E migradas de Dexie a Supabase
## [0.4.0] - 2026-06-18
### Added
- Autenticación Google Workspace OAuth + Supabase
- Migración completa IndexedDB (Dexie) → PostgreSQL (Supabase)
- Row Level Security con `is_platform_admin()` y políticas granulares
- Catálogo global de recursos (sin processId)
- Identidad en UI: avatar/dropdown AppHeader, "Actualizado por" en proceso
- ResourceCatalogPage + ResourceFormSheet
- Fix Web Locks API deadlock (processLock)
- Fix `.maybeSingle()` en simulation-repo
- Fix `formatCostPerHour()` precisión dinámica
- Fix Google OAuth `prompt: 'select_account'`
- Catálogo con contexto de proceso (banner + query param fromProcess)
## [0.3.0] - 2026-05-27
### Added
- Biblioteca de procesos con grupos/tags/búsqueda (Dexie v4)
- Indicador de simulación desactualizada ("Hay cambios sin simular")
- 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
## [0.2.0] - 2026-05-20
### Added
- Modelo de recursos `utilizationMinutes + units` (migración Dexie v3)
- ActivityPanel con cálculo de costo 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)
## [0.1.5] - 2026-05-18
### Added
- Identidad visual completa: paleta naranja InQ #F59845, gradiente → magenta #ED3E8F
- PDFs de clase profesional: 3 documentos (Actual, Automatizado, ROI)
- Badge ⚡ en canvas BPMN para actividades automatizables
- ROI con 5 KPIs
- Sistema de componentes establecido
## [0.1.0] - 2026-05-01
### Added
- MVP: importar BPMN 2.0, parametrizar actividades, simular costos, exportar PDF
- Motor de simulación AS-IS / TO-BE (Modelo A)
- Heatmap de costos en canvas BPMN
- Reporte web básico con tabla de actividades y gráfico de barras
```
**Nota sobre las fechas:** usar las fechas de cierre formal de sprint documentadas en `STRATEGIC_DIRECTION.md`. Si alguna fecha no está documentada, usar la fecha más cercana disponible (commits de git son referencia).
### 6. Actualizar `simulador-web/CLAUDE.md`
La sección "Gestión de versiones y commits" ya está en `CLAUDE.md` (fue agregada en el cierre de Sprint 7). Verificar que el contenido sea correcto y consistente con lo que se implementa en esta etapa. Si la sección no existe, crearla.
Verificar específicamente que diga:
- `Sprint 7 = v0.7.x (MVP multi-tenant). Sprint 8 arranca en v0.8.0.`
- El formato de Conventional Commits está documentado con los scopes correctos
**Si la sección ya existe y es correcta, NO modificar `CLAUDE.md`** — no tocar lo que ya está bien.
---
## Criterios de validación
### Funcionales
- [ ] `package.json` tiene `"version": "0.8.0"`
- [ ] `src/lib/version.ts` exporta `APP_VERSION = '0.8.0'`
- [ ] `AppFooter` renderiza `InQ ROI v0.8.0 · Powered by InQuality`
- [ ] Footer visible en LibraryPage (validación visual)
- [ ] Footer visible en ReportPage (validación visual)
- [ ] `CHANGELOG.md` existe con historial desde v0.1.0 hasta v0.8.0
### Técnicos
- [ ] Build sin errores TypeScript (`npm run build`)
- [ ] Tests sin regresiones (`npm run test`)
- [ ] No hay cambios en archivos fuera del scope listado
---
## Restricciones explícitas
- **NO** modificar `AppHeader.tsx` — la versión va en el footer solamente
- **NO** agregar modal de "novedades" ni changelog interactivo en la UI
- **NO** usar `import.meta.glob` ni mecanismos complejos para leer el version — la constante simple es suficiente
- **NO** agregar dependencias nuevas — todo con lo que ya existe en el stack
- **NO** crear README.md ni documentación adicional — solo los archivos listados
---
## Archivos autorizados a modificar/crear
```
simulador-web/
├── package.json ← actualizar "version" a "0.8.0"
├── CHANGELOG.md ← crear
├── src/
│ ├── lib/
│ │ └── version.ts ← crear
│ └── components/
│ └── AppFooter.tsx ← actualizar (solo versión dinámica)
└── src/features/library/LibraryPage.tsx ← agregar <AppFooter /> al final
```
**No hay otros archivos para tocar en esta etapa.** ReportPage e ImportPage ya tienen `<AppFooter />` y no se modifican.
---
## Commit esperado
```
chore(version): v0.8.0 — CHANGELOG, AppFooter, version.ts
```
Seguido de:
```
docs(changelog): etapa 0 sprint 8
```
(Si se hacen en un solo commit, usar el primer mensaje.)
---
## Reporte esperado
Al finalizar, reportar:
1. Valor previo de `"version"` en `package.json`
2. Si el layout global existe (y si AppFooter se puso ahí en lugar de en las páginas)
3. Lista exacta de archivos modificados con diff resumen
4. Resultado de `npm run build` y `npm run test`
5. Screenshot o descripción del footer visible en la app (para validación visual del director)

View File

@@ -0,0 +1,205 @@
# Sprint 8 — Etapa 1: Migración DB (aditiva)
> **Tipo:** Base de datos — migración aditiva
> **Rama:** main
> **Estimación:** 1-2 horas
> **Dependencias:** Etapa 0 completada ✅
---
## Objetivo
Crear la infraestructura de datos para el modelo `organizations → areas → processes` sin romper nada de lo que ya funciona. Esta migración es **completamente aditiva**: crea lo nuevo, no elimina lo viejo. Las columnas `client` y `group_id` de `processes`, y la tabla `process_groups`, se eliminan en una migration de limpieza posterior (después de que Etapas 3-4 migren la UI).
---
## Contexto del schema actual
Leer las migraciones en `supabase/migrations/` antes de escribir cualquier SQL. Schema relevante al momento de esta etapa:
**`processes` — columnas existentes relevantes:**
- `client text NOT NULL DEFAULT ''` — el campo libre "clientName" (se elimina más adelante)
- `group_id uuid REFERENCES public.process_groups(id) ON DELETE SET NULL` — agrupador viejo (se elimina más adelante)
- `org_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL` — ← este es el correcto, agregado en migración 014
**`process_groups` — tabla existente:**
- `id, name, created_by, created_at, updated_at` — no tiene FKs entrantes salvo `processes.group_id`
**`organizations` — tabla existente desde migración 014:**
- InQuality existe con `is_provider = true`
- Otras orgs creadas automáticamente desde `processes.client` en la migración 014
**Funciones SECURITY DEFINER disponibles (migración 015):**
- `public.is_inquality()` → true si el usuario pertenece a InQuality
- `public.current_org()` → org_id del usuario actual
- `public.current_platform_role()` → rol del usuario actual
- `public.is_platform_admin()` → true si es platform_admin
---
## Alcance estricto — Migration 017
Crear el archivo `supabase/migrations/017_create_areas.sql`.
### Paso 1: Crear tabla `areas`
```sql
-- Nueva tabla areas: reemplazará process_groups como agrupador normalizado por org
CREATE TABLE IF NOT EXISTS public.areas (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES public.organizations(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.areas ENABLE ROW LEVEL SECURITY;
```
### Paso 2: Agregar `area_id` a `processes`
```sql
-- area_id reemplazará group_id como agrupador normalizado dentro de una org
-- Nullable: procesos sin área asignada son válidos ("Sin área")
ALTER TABLE public.processes
ADD COLUMN IF NOT EXISTS area_id uuid REFERENCES public.areas(id) ON DELETE SET NULL;
```
### Paso 3: RLS para `areas`
```sql
-- SELECT: InQuality ve todas las áreas; cliente ve solo las de su org
CREATE POLICY "areas_select" ON public.areas
FOR SELECT USING (
public.is_inquality()
OR org_id = public.current_org()
);
-- INSERT / UPDATE / DELETE: solo platform_admin
CREATE POLICY "areas_insert" ON public.areas
FOR INSERT WITH CHECK (
public.current_platform_role() = 'platform_admin'
);
CREATE POLICY "areas_update" ON public.areas
FOR UPDATE USING (
public.current_platform_role() = 'platform_admin'
);
CREATE POLICY "areas_delete" ON public.areas
FOR DELETE USING (
public.current_platform_role() = 'platform_admin'
);
```
### Paso 4: Migración de datos — asignar procesos sin org a InQuality
```sql
-- Procesos sin org_id son datos de prueba de InQuality.
-- Asignarlos a la org de InQuality (is_provider = true) para limpiar los NULLs.
UPDATE public.processes
SET org_id = (
SELECT id FROM public.organizations WHERE is_provider = true LIMIT 1
)
WHERE org_id IS NULL;
```
### Lo que esta migración NO hace (intencional)
```
❌ NO dropea la columna `client` de processes
❌ NO dropea la columna `group_id` de processes
❌ NO dropea la tabla `process_groups`
❌ NO modifica ninguna política RLS existente
```
Estas eliminaciones ocurren en una migration posterior, después de que la UI deje de usar esas columnas.
---
## Verificación post-migración
Después de aplicar la migración en Supabase (SQL Editor o `supabase db push`), verificar con estas queries:
```sql
-- 1. Verificar que la tabla areas existe con RLS activa
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public' AND tablename = 'areas';
-- Esperado: rowsecurity = true
-- 2. Verificar columna area_id en processes
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'processes'
AND column_name = 'area_id';
-- Esperado: una fila, data_type = 'uuid', is_nullable = 'YES'
-- 3. Verificar que no quedan procesos sin org_id
SELECT COUNT(*) as sin_org FROM public.processes WHERE org_id IS NULL;
-- Esperado: 0
-- 4. Verificar políticas de areas
SELECT policyname, cmd FROM pg_policies
WHERE schemaname = 'public' AND tablename = 'areas';
-- Esperado: 4 políticas (areas_select, areas_insert, areas_update, areas_delete)
-- 5. Verificar que process_groups sigue existiendo (no se eliminó)
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'process_groups';
-- Esperado: 1 fila (la tabla sigue ahí — se elimina más adelante)
```
---
## Criterios de validación
- [ ] Archivo `supabase/migrations/017_create_areas.sql` creado
- [ ] Migración aplicada en Supabase (reportar resultado del SQL Editor o `supabase db push`)
- [ ] Las 5 queries de verificación retornan los valores esperados
- [ ] `npm run build` sin errores (no hay cambios en TypeScript — build debe ser idéntico al de Etapa 0)
- [ ] `npm run test` sin regresiones (592 tests, sin cambios)
- [ ] `process_groups` sigue existiendo en la DB (verificar con query 5)
---
## Restricciones explícitas
- **NO** modificar ningún archivo TypeScript en esta etapa — solo SQL
- **NO** dropear `process_groups`, `client`, ni `group_id` — eso viene después
- **NO** tocar las políticas RLS existentes en `processes`, `organizations`, ni otras tablas
- **NO** crear datos de prueba en `areas` — la tabla arranca vacía
---
## Archivos autorizados a modificar/crear
```
simulador-web/
└── supabase/
└── migrations/
└── 017_create_areas.sql ← único archivo a crear
```
Ningún archivo TypeScript debe cambiar en esta etapa.
---
## Commit esperado
```
feat(db): migración 017 — tabla areas, area_id en processes, RLS, bulk-assign org
```
---
## Reporte esperado
Al finalizar, reportar:
1. Contenido exacto del archivo `017_create_areas.sql`
2. Resultado de aplicar la migración (output de Supabase)
3. Resultado de las 5 queries de verificación (con los valores reales)
4. Resultado de `npm run build` y `npm run test`
5. Confirmar que `process_groups` sigue existiendo (query 5)

View File

@@ -0,0 +1,262 @@
# Sprint 8 — Etapa 2: Repositorios y tipos de dominio
> **Tipo:** Backend TypeScript — dominio + repos
> **Rama:** main
> **Estimación:** 1.5-2 horas
> **Dependencias:** Etapa 1 completada ✅ (tabla `areas` y columna `area_id` en `processes` ya existen en DB)
---
## Objetivo
Extender la capa de dominio y repositorios para soportar el nuevo campo `area_id`. Esto es **no destructivo**: se agregan los campos nuevos sin eliminar los viejos (`clientName`, `groupId`), que todavía son usados por la UI hasta que Etapas 3 y 4 los remplacen.
Al terminar esta etapa, el dominio conoce `areaId/areaName`, el repositorio lee y escribe `area_id` en Supabase, y el código de UI existente sigue funcionando sin cambios.
---
## Contexto del código actual
Leer estos archivos antes de modificar nada:
- `src/domain/types.ts``Process` tiene `clientName: string`, `groupId: UUID | null`, `orgId`, `orgName`. Sin `areaId` aún.
- `src/persistence/supabase/process-repo.ts``fromRow()` extrae `clientName` y `groupId`; `toRow()` escribe `client` y `group_id`; `getById()` hace JOIN con `organizations!org_id(name)`.
- `src/persistence/supabase/group-repo.ts` — repositorio del agrupador viejo `process_groups`. **No tocar** — sigue siendo usado por la Settings page.
- `src/domain/schemas.ts` — leer el contenido actual antes de modificar.
---
## Alcance
### 1. Actualizar `src/domain/types.ts`
**A. Agregar interface `Area`:**
```typescript
export interface Area {
id: string
orgId: string
name: string
description: string | null
createdAt: number // timestamp ms, igual que Process.createdAt
}
```
Agregar después de la interface `ProcessGroup` existente (o en una sección nueva de tipos de organización).
**B. Extender interface `Process`:**
Agregar `areaId` y `areaName` **después** de `orgName`:
```typescript
orgId: string | null // 🆕 Sprint 7
orgName?: string | null // 🆕 Sprint 7 — poblado via JOIN en getById
areaId: string | null // 🆕 Sprint 8 — área de la org (null = sin área)
areaName?: string | null // 🆕 Sprint 8 — poblado via JOIN en getById
```
**NO eliminar** `clientName: string` ni `groupId: UUID | null` — siguen siendo necesarios hasta Etapas 3-4.
### 2. Actualizar `src/domain/schemas.ts`
Leer el archivo primero y reportar su contenido.
Agregar `areaId` al schema de Process:
```typescript
areaId: z.string().uuid().nullable(),
```
**NO eliminar** `clientName` ni `groupId` del schema — todavía se usan.
Si `schemas.ts` no tiene un schema de Process (posiblemente solo tiene schemas para formularios), reportarlo antes de modificar.
### 3. Crear `src/persistence/supabase/area-repo.ts`
Nuevo repositorio para la tabla `areas`. Seguir el mismo patrón que `group-repo.ts`.
```typescript
import { supabase } from '@/lib/supabase'
import type { Area } from '@/domain/types'
function fromRow(row: Record<string, unknown>): Area {
return {
id: row.id as string,
orgId: row.org_id as string,
name: row.name as string,
description: (row.description as string | null) ?? null,
createdAt: new Date(row.created_at as string).getTime(),
}
}
export const supabaseAreaRepo = {
// Todas las áreas de una organización, ordenadas por nombre
async getByOrg(orgId: string): Promise<Area[]> {
const { data, error } = await supabase
.from('areas')
.select('*')
.eq('org_id', orgId)
.order('name', { ascending: true })
if (error) throw error
return (data ?? []).map(fromRow)
},
// Crear área nueva en una org
async create(data: { orgId: string; name: string; description?: string }): Promise<Area> {
const { data: row, error } = await supabase
.from('areas')
.insert({
org_id: data.orgId,
name: data.name,
description: data.description ?? null,
})
.select('*')
.single()
if (error) throw error
return fromRow(row as Record<string, unknown>)
},
// Actualizar nombre y/o descripción de un área
async update(id: string, data: { name?: string; description?: string }): Promise<Area> {
const { data: row, error } = await supabase
.from('areas')
.update({
...(data.name !== undefined && { name: data.name }),
...(data.description !== undefined && { description: data.description }),
updated_at: new Date().toISOString(),
})
.eq('id', id)
.select('*')
.single()
if (error) throw error
return fromRow(row as Record<string, unknown>)
},
// Eliminar área. Los procesos con esta área pasan a area_id = null (ON DELETE SET NULL)
async delete(id: string): Promise<void> {
const { error } = await supabase.from('areas').delete().eq('id', id)
if (error) throw error
},
}
```
### 4. Actualizar `src/persistence/supabase/process-repo.ts`
Cuatro cambios específicos en el archivo existente:
**4A — `fromRow()`: extraer `areaId` y `areaName`**
Agregar a la función existente, después de la extracción de `org`:
```typescript
// area puede estar presente si el query hizo JOIN con areas (getById lo incluye)
const area = row.area as { name: string } | null
```
Y en el objeto de retorno, después de `orgName`:
```typescript
areaId: (row.area_id as string | null) ?? null,
areaName: area?.name ?? null,
```
**4B — `toRow()`: incluir `area_id`**
Agregar al objeto de retorno de `toRow()`, después de `group_id`:
```typescript
area_id: process.areaId,
```
**4C — `updateEditable()`: incluir `area_id`**
Agregar al objeto del `.update()`, después de `group_id`:
```typescript
area_id: process.areaId,
```
**4D — `getById()`: extender el JOIN para incluir área**
Cambiar el select actual:
```typescript
// Antes:
.select('*, org:organizations!org_id(name)')
// Después:
.select('*, org:organizations!org_id(name), area:areas!area_id(name)')
```
**Lo que NO cambia en `process-repo.ts`:**
- `client: process.clientName` en `toRow()` — permanece
- `group_id: process.groupId` en `toRow()` — permanece
- `client: process.clientName` en `updateEditable()` — permanece
- `group_id: process.groupId` en `updateEditable()` — permanece
- `fromRow()` sigue extrayendo `clientName` y `groupId` — permanece
- `getAll()` — no cambia (no hace JOIN con areas, solo devuelve `area_id` como UUID crudo via `select('*')`)
---
## Tests
Agregar al menos 2 tests unitarios para `area-repo.ts`. Seguir el patrón de los tests existentes de repositorios (probablemente con `vi.mock('@/lib/supabase')`).
Tests mínimos:
1. `getByOrg()` devuelve lista correcta de áreas
2. `create()` inserta y retorna el área creada
---
## Criterios de validación
- [ ] `Area` interface existe en `types.ts`
- [ ] `Process.areaId` y `Process.areaName` existen en `types.ts` (sin eliminar `clientName`/`groupId`)
- [ ] `area-repo.ts` existe con las 4 operaciones (getByOrg, create, update, delete)
- [ ] `process-repo.ts`: `fromRow()` extrae `areaId` y `areaName`, `toRow()` incluye `area_id`, `updateEditable()` incluye `area_id`, `getById()` hace JOIN con `areas`
- [ ] `npm run build` sin errores TypeScript
- [ ] `npm run test` sin regresiones + 2 tests nuevos de `area-repo.ts`
- [ ] Ningún archivo de UI modificado (LibraryPage, WorkspacePage, etc. no cambian)
---
## Restricciones explícitas
- **NO eliminar** `clientName`, `groupId` de `types.ts` — aún usados por UI
- **NO modificar** `group-repo.ts` — sigue siendo usado por la Settings page
- **NO modificar** `library-store.ts` — los cambios de store van en Etapa 3
- **NO modificar** ningún componente React — solo capa de dominio y persistencia
- **NO agregar** `area_id` al `select('*')` de `getAll()` explícitamente — ya está incluido en el wildcard
---
## Archivos autorizados a modificar/crear
```
src/
├── domain/
│ ├── types.ts ← agregar Area interface + areaId/areaName a Process
│ └── schemas.ts ← agregar areaId al schema (leer primero)
└── persistence/
└── supabase/
├── area-repo.ts ← crear
└── process-repo.ts ← 4 cambios puntuales
```
Opcionalmente, un archivo de tests para `area-repo.ts` en `tests/`.
---
## Commit esperado
```
feat(area-repo): repositorio CRUD de áreas por organización
feat(process-repo): extender fromRow/toRow/getById con areaId y areaName
feat(domain): interface Area + campos areaId/areaName en Process
```
---
## Reporte esperado
1. Contenido actual de `schemas.ts` (antes de modificar)
2. Diff compacto de `types.ts`
3. Diff compacto de `process-repo.ts`
4. Contenido de `area-repo.ts` creado
5. Resultado de `npm run build` y `npm run test` (con conteo de tests)
6. Confirmar que `clientName` y `groupId` siguen presentes en `types.ts`

View File

@@ -0,0 +1,432 @@
# Sprint 8 — Etapa 3: LibraryPage — agrupación org → área
> **Tipo:** UI — refactorización de vista de biblioteca
> **Rama:** main
> **Estimación:** 2-3 horas
> **Dependencias:** Etapas 1 y 2 completadas ✅
---
## Objetivo
Reemplazar la agrupación por `process_groups` / `groupId` en LibraryPage con una agrupación real por `organizations → areas`. El resultado visual: el admin ve sus procesos organizados por cliente (org) con sub-secciones por área, colapsables. Los roles cliente no cambian (ya tienen lista plana).
---
## Contexto del código actual
Leer en su totalidad antes de modificar:
- `src/features/library/LibraryPage.tsx` (942 líneas)
- `src/persistence/supabase/process-repo.ts` (para el cambio en `getAll()`)
- `src/router.tsx` (para el guard de `/settings`)
**Estructura actual relevante de LibraryPage:**
- `HomeView` para admin: grid de `GroupCard` (desde `process_groups` via `library-store.groups`) + sección "Sin clasificar" filtrada por `groupId == null`
- `HomeView` para cliente: lista plana de procesos (sin cambios en esta etapa)
- `GroupView`: lista de procesos filtrada por `groupId` — se preserva como código muerto (no eliminar)
- `LibraryPage`: botón Settings ⚙ visible para todos los roles (línea ~891)
- `LibraryPage`: efecto `orgParam` que hace scroll a `group-${proc.groupId}` (líneas ~793-800)
**Dato importante:** `getAll()` en `process-repo.ts` actualmente NO hace JOIN con org ni area, por lo que los procesos retornados tienen `orgName: null` y `areaName: null`. Esta etapa extiende ese JOIN como primer paso.
---
## Alcance
### Cambio 1: Extender `getAll()` en `process-repo.ts`
Agregar org y area al select de `getAll()`. Cambio de una línea:
```typescript
// Antes:
.select('*, owner:users!owner_id(name, avatar_url), updater:users!updated_by(name)')
// Después:
.select('*, owner:users!owner_id(name, avatar_url), updater:users!updated_by(name), org:organizations!org_id(name), area:areas!area_id(name)')
```
`fromRow()` ya sabe extraer `orgName` y `areaName` desde los JOINs (implementado en Etapa 2). Los tests existentes siguen pasando porque `fromRow()` usa null-safe extraction (`org?.name ?? null`).
### Cambio 2: Refactorizar `HomeView` — sección admin
**Reemplazar** el bloque `{!isSearching && !isClientRole && (...)}` (líneas 543-631 aproximadamente) por una lista agrupada org → área.
**Nueva estructura visual para admin:**
```
[Org: Solar Banco] ← header colapsable con ícono Building2 y badge de conteo
[Área: Operaciones] ← sub-header con ícono FolderOpen
[ProcessCard] [ProcessCard]
[Área: RR.HH.]
[ProcessCard]
[Sin área] ← solo si existen procesos sin area_id
[ProcessCard]
[Org: InQuality]
[Sin área]
[ProcessCard]
```
**Lógica de agrupación** (computar desde `visibleProcesses`):
```typescript
// Agrupar por orgId → areaId
const orgGroups = useMemo(() => {
const byOrg = new Map<string, {
orgId: string
orgName: string
processes: ProcessWithUserNames[]
}>()
for (const p of visibleProcesses) {
const key = p.orgId ?? '__sin_org'
const orgName = p.orgName ?? 'Sin organización'
if (!byOrg.has(key)) {
byOrg.set(key, { orgId: key, orgName, processes: [] })
}
byOrg.get(key)!.processes.push(p)
}
return [...byOrg.values()].sort((a, b) => a.orgName.localeCompare(b.orgName))
}, [visibleProcesses])
// Dentro de cada org, agrupar por areaId
function getAreaGroups(orgProcesses: ProcessWithUserNames[]) {
const byArea = new Map<string, {
areaId: string
areaName: string
processes: ProcessWithUserNames[]
}>()
for (const p of orgProcesses) {
const key = p.areaId ?? '__sin_area'
const areaName = p.areaName ?? 'Sin área'
if (!byArea.has(key)) {
byArea.set(key, { areaId: key, areaName, processes: [] })
}
byArea.get(key)!.processes.push(p)
}
// "Sin área" siempre al final
const sorted = [...byArea.values()].sort((a, b) => {
if (a.areaId === '__sin_area') return 1
if (b.areaId === '__sin_area') return -1
return a.areaName.localeCompare(b.areaName)
})
return sorted
}
```
**Estado de colapso por org** (criterio de wow):
```typescript
const [collapsedOrgs, setCollapsedOrgs] = useState<Set<string>>(new Set())
function toggleOrg(orgId: string) {
setCollapsedOrgs(prev => {
const next = new Set(prev)
if (next.has(orgId)) next.delete(orgId)
else next.add(orgId)
return next
})
}
```
**Renderizado del bloque admin:**
```tsx
{!isSearching && !isClientRole && (
<div className="space-y-3">
{/* Tag cloud — mantener igual que antes */}
{cloud.length >= 3 && (...)}
{/* Grupos org → área */}
{orgGroups.map(({ orgId, orgName, processes: orgProcesses }) => {
const isCollapsed = collapsedOrgs.has(orgId)
const areaGroups = getAreaGroups(orgProcesses)
return (
<div key={orgId} className="border border-border rounded-xl overflow-hidden">
{/* Header de org — colapsable */}
<button
onClick={() => toggleOrg(orgId)}
className="w-full flex items-center justify-between px-4 py-3 bg-secondary/40 hover:bg-secondary/60 transition-colors"
>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span className="text-[13px] font-medium">{orgName}</span>
<span className="text-[11px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground">
{orgProcesses.length}
</span>
</div>
<ChevronDown
className="h-4 w-4 text-muted-foreground transition-transform duration-200"
style={{ transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
/>
</button>
{/* Contenido — animado */}
<div
className="transition-all duration-200 overflow-hidden"
style={{ maxHeight: isCollapsed ? 0 : '9999px', opacity: isCollapsed ? 0 : 1 }}
>
<div className="p-3 space-y-3">
{areaGroups.map(({ areaId, areaName, processes: areaProcesses }) => (
<div key={areaId}>
{/* Sub-header de área (solo si hay más de un área o existe un área nombrada) */}
{(areaGroups.length > 1 || areaId !== '__sin_area') && (
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-2 flex items-center gap-1.5">
<FolderOpen className="h-3 w-3" />
{areaName}
<span className="normal-case tracking-normal font-normal">
· {areaProcesses.length}
</span>
</p>
)}
<div className="space-y-2">
{areaProcesses.map((p) => (
<ProcessCardWithSim
key={p.id}
process={p}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
})}
{/* Empty state: admin sin procesos */}
{orgGroups.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Building2 className="h-12 w-12 text-muted-foreground/30" />
<p className="text-[13px] font-medium text-secondary-foreground">
Tu biblioteca está vacía
</p>
<p className="text-[13px] text-muted-foreground">
Importá tu primer BPMN para empezar
</p>
<button
onClick={() => onImport()}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white"
style={{ background: '#F59845' }}
>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
</div>
)}
</div>
)}
```
**Agregar `ChevronDown` y `FolderOpen` a los imports de lucide-react** en el archivo. Verificar que no estén ya importados.
### Cambio 3: Actualizar resultados de búsqueda
En el bloque de `filteredProcesses.map()` (búsqueda activa), reemplazar la resolución del nombre de grupo por area/org:
```typescript
// Antes:
const group = groups.find((g) => g.id === p.groupId)
<ProcessCardWithSim showGroupChip groupName={group?.name} ... />
// Después:
<ProcessCardWithSim showGroupChip groupName={p.areaName ?? p.orgName ?? undefined} ... />
```
### Cambio 4: Actualizar header de LibraryPage
La línea que muestra `{totalGroups} {settings.groupLabel.toLowerCase()}s` no aplica al nuevo modelo. Reemplazar por conteo de orgs:
```typescript
// Antes:
{processes.length} proceso{...} · {totalGroups} {settings.groupLabel.toLowerCase()}{...}
// Después (para admin):
{processes.length} proceso{processes.length !== 1 ? 's' : ''} · {orgGroups.length} cliente{orgGroups.length !== 1 ? 's' : ''}
// Para cliente: mostrar solo procesos (sin conteo de org)
{processes.length} proceso{processes.length !== 1 ? 's' : ''}
```
Nota: `orgGroups` se computa en `HomeView`, no en `LibraryPage`. Para el header en `LibraryPage`, usar un conteo simple:
```typescript
const orgCount = new Set(processes.map(p => p.orgId).filter(Boolean)).size
```
Y en el JSX del header:
```typescript
{isClientRole
? `${processes.length} proceso${processes.length !== 1 ? 's' : ''}`
: `${processes.length} proceso${processes.length !== 1 ? 's' : ''} · ${orgCount} cliente${orgCount !== 1 ? 's' : ''}`
}
```
### Cambio 5: Ocultar botón Settings para roles cliente
En `LibraryPage` (líneas ~891-897), envolver el botón de Settings:
```tsx
// Antes: el botón de Settings está suelto, visible para todos
// Después: solo visible para admin/member
{!isClientRole && (
<button
onClick={() => navigate({ to: '/settings' })}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
title="Configuración"
>
<Settings className="h-4 w-4" />
</button>
)}
```
### Cambio 6: Guard de ruta `/settings` en `router.tsx`
Agregar `beforeLoad` a `settingsRoute` para bloquear acceso de roles cliente:
```typescript
// En router.tsx, actualmente:
const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/settings',
component: SettingsPage,
})
// Nuevo:
const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/settings',
component: SettingsPage,
beforeLoad: ({ context }: { context: RouterContext }) => {
const role = (context as { auth?: { user?: { platformRole?: string } } }).auth?.user?.platformRole
if (role === 'client_editor' || role === 'client_viewer') {
throw redirect({ to: '/library' })
}
},
})
```
Verificar primero cómo está tipado `context` en los guards existentes del router (por ejemplo en `adminLayoutRoute`) y usar el mismo patrón.
### Cambio 7: Eliminar el efecto `orgParam` obsoleto
Las líneas ~793-800:
```typescript
useEffect(() => {
if (!orgParam || isLoading || processes.length === 0) return
const proc = processes.find((p) => p.orgId === orgParam)
if (!proc?.groupId) return // ← depende de groupId, ya no válido
requestAnimationFrame(() => {
document.getElementById(`group-${proc.groupId}`)?.scrollIntoView(...)
})
}, [orgParam, isLoading, processes])
```
Este efecto busca un proceso por `orgId` y hace scroll a `group-${proc.groupId}` — un ID que ya no existe en el nuevo DOM. Eliminar el efecto completo. La variable `orgParam` puede eliminarse también si ya no se usa en ningún otro lugar.
### Cambio 8: Simplificar `handleImport`
```typescript
// Antes:
function handleImport(groupId?: string) {
if (groupId) {
navigate({ to: '/import', search: { groupId } })
} else {
navigate({ to: '/import' })
}
}
// Después:
function handleImport() {
navigate({ to: '/import' })
}
```
Y actualizar todas las llamadas a `handleImport` para no pasar argumentos.
---
## Lo que NO cambia en esta etapa
- **`GroupView`**: mantener el componente intacto — se convierte en código no alcanzable desde el admin, pero no eliminar (evita riesgo de romper algo)
- **`library-store.ts`**: sin cambios — sigue cargando `groups` para la Settings page
- **Vista cliente de `HomeView`**: sin cambios (lista plana, funciona igual)
- **`group-repo.ts`**: sin cambios
- **`WorkspacePage.tsx`**: sin cambios (Etapa 4)
- **`GlobalSettingsPanel.tsx`**: sin cambios (Etapa 4)
- **`ProcessGroup` interface en types.ts**: sin cambios
- **Props `showGroupChip` / `groupName` de `ProcessCard`**: adaptar pero no eliminar
---
## Criterios de validación
### Funcionales (validación visual mandatoria)
- [ ] Admin ve procesos agrupados por org → área en la biblioteca
- [ ] Hacer click en header de org colapsa/expande la sección con animación
- [ ] "Sin área" aparece al final de cada org (si existen procesos sin área)
- [ ] Roles cliente siguen viendo lista plana (sin cambio)
- [ ] Botón Settings ⚙ no visible para client_editor y client_viewer
- [ ] Navegar a `/settings` como client_editor o client_viewer redirige a `/library`
- [ ] Búsqueda muestra chip con nombre de área u org (en lugar de nombre de grupo)
- [ ] El botón "Importar BPMN" funciona y lleva a `/import`
### Técnicos
- [ ] `npm run build` sin errores TypeScript
- [ ] `npm run test` sin regresiones (596 tests actuales)
- [ ] `getAll()` ahora incluye org y area en el select — verificar en Network tab que el request lleva el select extendido
- [ ] `orgParam` y el efecto de scroll obsoleto eliminados
---
## Restricciones explícitas
- **NO eliminar** `GroupView` — preservar aunque sea código no alcanzable
- **NO modificar** `library-store.ts`, `group-repo.ts` ni la Settings page
- **NO eliminar** props `showGroupChip`/`groupName` de `ProcessCard` — solo actualizarlos
- **NO tocar** `WorkspacePage.tsx` ni `GlobalSettingsPanel.tsx` — Etapa 4
- **NO tocar** `clientName` ni `groupId` en el dominio — Etapas posteriores
- **NO eliminar** los datos de `groups` del store — la Settings page los sigue usando
---
## Archivos autorizados a modificar
```
src/
├── persistence/supabase/
│ └── process-repo.ts ← 1 línea: extender select de getAll()
├── features/library/
│ └── LibraryPage.tsx ← refactorización principal
└── router.tsx ← guard beforeLoad en settingsRoute
```
---
## Commits esperados
```
feat(library): agrupación org → área reemplaza groupId en HomeView
feat(router): guard beforeLoad en /settings para client_editor y client_viewer
fix(process-repo): getAll() incluye JOIN con org y area para populear names
```
---
## Reporte esperado
1. Descripción de cómo quedó el nuevo bloque admin de `HomeView` (estructura visual)
2. Confirmar que `GroupView` sigue en el archivo (código no alcanzable, no eliminado)
3. Lista de archivos modificados con diff compacto
4. `npm run build` y `npm run test` (con conteo)
5. **Solicitar validación visual del director** antes de declarar OK — mostrar descripción del estado de la UI para que el director valide en la app

View File

@@ -0,0 +1,266 @@
# Sprint 8 — Etapa 4.5: Fixes de navegación e InQuality
> **Tipo:** UI — fixes puntuales
> **Rama:** main
> **Estimación:** 1 hora
> **Dependencias:** Etapa 4 completada ✅
---
## Objetivo
Dos fixes independientes detectados en validación de Etapa 4:
1. **InQuality invisible en admin:** `OrganizationsPage` filtra con `.eq('is_provider', false)`, dejando fuera a InQuality. El equipo no puede gestionar sus propias áreas ni procesos desde el panel. Fix: mostrar todas las orgs, con un badge visual para InQuality.
2. **Back button sin contexto de org:** En el workspace, el botón "← Solar Banco" (o cualquier org) navega a `/library` sin parámetros — el usuario llega al home genérico en lugar de la sección de esa org. Fix: pasar `?org=<orgId>` al navegar y hacer scroll al bloque correcto en LibraryPage.
---
## Fix 1: `OrganizationsPage.tsx`
### Problema actual
Línea en `fetchOrgs()`:
```typescript
.eq('is_provider', false)
```
Esta condición excluye a InQuality (que tiene `is_provider = true`) de la lista. El equipo de InQuality no puede entrar a `/admin/organizations/:id` de su propia org para gestionar áreas y procesos.
### Cambios en `OrganizationsPage.tsx`
**1A. Extender la interface `OrgRow`:**
```typescript
interface OrgRow {
id: string
name: string
is_provider: boolean // 🆕
created_at: string
processes: [{ count: number }]
users: [{ count: number }]
}
```
**1B. Actualizar `fetchOrgs()` — quitar el filtro y agregar `is_provider` al select:**
```typescript
const fetchOrgs = async () => {
const { data } = await supabase
.from('organizations')
.select('id, name, is_provider, created_at, processes:processes(count), users:users(count)')
// ← eliminar el .eq('is_provider', false)
.order('name')
// Ordenar: proveedor primero, luego clientes alfabético
const sorted = ((data as OrgRow[] | null) ?? []).sort((a, b) => {
if (a.is_provider && !b.is_provider) return -1
if (!a.is_provider && b.is_provider) return 1
return a.name.localeCompare(b.name)
})
setOrgs(sorted)
setLoading(false)
}
```
**1C. Actualizar el título:**
```tsx
// Antes:
<h2 className="text-lg font-semibold text-slate-900">Organizaciones clientes</h2>
// Después:
<h2 className="text-lg font-semibold text-slate-900">Organizaciones</h2>
```
**1D. Agregar badge "Proveedor" en la columna de nombre:**
En la fila de la tabla, en la celda de nombre:
```tsx
<td className="py-3 px-4 font-medium text-slate-900">
<div className="flex items-center gap-2">
{org.name}
{org.is_provider && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full border"
style={{ color: '#F59845', background: '#fff8f0', borderColor: '#fcd9aa' }}>
InQuality
</span>
)}
</div>
</td>
```
No cambiar el botón "Ver detalle →" — funciona igual para InQuality que para cualquier otra org.
---
## Fix 2: Back button con contexto de org
Son tres cambios coordinados: `router.tsx`, `WorkspacePage.tsx`, y `LibraryPage.tsx`.
### Fix 2A: `router.tsx` — declarar `org` como search param de `/library`
TanStack Router requiere que los search params estén declarados en la ruta para que `navigate({ search: ... })` sea type-safe.
```typescript
// Antes:
const libraryRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/library',
component: LibraryPage,
})
// Después:
const libraryRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/library',
component: LibraryPage,
validateSearch: (search: Record<string, unknown>) => ({
org: typeof search.org === 'string' ? search.org : undefined,
}),
})
```
**NO tocar** `indexRoute` (`/`) — no necesita el param porque nunca se navega a `/` desde el workspace.
### Fix 2B: `WorkspacePage.tsx` — pasar org en `handleBack`
```typescript
// Antes (línea ~194-196):
function handleBack() {
void navigate({ to: '/library' })
}
// Después:
function handleBack() {
void navigate({
to: '/library',
search: currentProcess.orgId ? { org: currentProcess.orgId } : {},
})
}
```
### Fix 2C: `LibraryPage.tsx` — leer param y hacer scroll
**Imports:** agregar `useLocation` al import existente de `@tanstack/react-router`:
```typescript
// Antes:
import { useNavigate } from '@tanstack/react-router'
// Después:
import { useNavigate, useLocation } from '@tanstack/react-router'
```
**Leer el param** (agregar dentro del cuerpo de `LibraryPage`, junto a los otros hooks):
```typescript
const location = useLocation()
const orgParam = useMemo(
() => new URLSearchParams(location.search).get('org') ?? undefined,
[location.search]
)
```
`useMemo` ya está importado en el archivo.
**Efecto de scroll** (agregar después de los efectos existentes):
```typescript
// Scroll a la sección del org si venimos del workspace
useEffect(() => {
if (!orgParam || isLoading || processes.length === 0) return
requestAnimationFrame(() => {
const el = document.getElementById(`org-section-${orgParam}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}, [orgParam, isLoading, processes.length])
```
**Agregar `id` al div de cada org en el bloque admin** (en el `orgGroups.map(...)`):
```tsx
// Antes:
<div key={orgId} className="border border-border rounded-xl overflow-hidden">
// Después:
<div key={orgId} id={`org-section-${orgId}`} className="border border-border rounded-xl overflow-hidden">
```
Solo en el div raíz de cada org (el que tiene el `key={orgId}`). No tocar divs internos.
---
## Lo que NO cambia en esta etapa
- **`src/features/admin/OrganizationDetailPage.tsx`** — sin cambios; el detalle de InQuality ya funciona una vez que aparece en la lista.
- **`src/store/library-store.ts`** — sin cambios.
- **Cualquier otro archivo** — sin cambios.
---
## Criterios de validación
### Fix 1 — InQuality visible
- [ ] La página `/admin/organizations` muestra a InQuality en la lista
- [ ] InQuality aparece primero (antes que las orgs clientes)
- [ ] InQuality tiene el badge naranja "InQuality"
- [ ] Al hacer click en "Ver detalle →" de InQuality, lleva a su detalle con procesos, usuarios y áreas
- [ ] Las otras orgs clientes siguen apareciendo igual que antes
### Fix 2 — Back button con scroll
- [ ] Abrir un proceso de "Solar Banco" → workspace → botón "← Solar Banco" → navega a `/library?org=<orgId>`
- [ ] Al llegar a `/library`, la página hace scroll automático al bloque de "Solar Banco"
- [ ] El bloque de "Solar Banco" está expandido (todas las orgs empiezan expandidas por default)
- [ ] Si el proceso no tiene org (edge case), el back button va a `/library` sin param y no hay scroll
### Técnicos
- [ ] `npm run build` sin errores TypeScript
- [ ] `npm run test` sin regresiones (596 tests)
---
## Restricciones explícitas
- **NO agregar** `validateSearch` a `indexRoute` (`/`) — solo a `libraryRoute`
- **NO modificar** `OrganizationDetailPage.tsx` en esta etapa
- **NO cambiar** el comportamiento del botón "Nueva organización" — InQuality se crea con `is_provider: false`, InQuality nunca se crea desde la UI (es dato de seeding)
- **NO agregar** tests en esta etapa
---
## Archivos autorizados a modificar
```
src/
├── router.tsx ← validateSearch en libraryRoute
├── features/
│ ├── workspace/
│ │ └── WorkspacePage.tsx ← handleBack con search: { org }
│ ├── library/
│ │ └── LibraryPage.tsx ← useLocation + scroll effect + id en org divs
│ └── admin/
│ └── OrganizationsPage.tsx ← quitar filtro is_provider + badge + título
```
---
## Commits esperados
```
fix(admin): InQuality visible en OrganizationsPage con badge — quitar filtro is_provider
fix(workspace): handleBack pasa ?org=orgId para scroll contextual en biblioteca
fix(library): scroll a sección de org al volver del workspace con ?org param
```
---
## Reporte esperado
1. Confirmar que InQuality aparece en la lista de organizaciones con el badge
2. Confirmar que el back button del workspace pasa `?org=` en la URL
3. Confirmar que la biblioteca hace scroll al bloque correcto al recibir el param
4. `npm run build` y `npm run test` (con conteo)
5. **Solicitar validación visual del director** en los dos fixes antes del OK formal

View File

@@ -0,0 +1,622 @@
# Sprint 8 — Etapa 4: Selector de área en workspace + CRUD de áreas en admin
> **Tipo:** UI — workspace sidebar + panel de administración
> **Rama:** main
> **Estimación:** 2-3 horas
> **Dependencias:** Etapas 1, 2 y 3 completadas ✅
> (tabla `areas` en DB, `area-repo.ts` con CRUD, `areaId`/`areaName` en tipos y repos)
---
## Objetivo
Conectar el campo `area_id` al flujo de trabajo real del usuario en dos lugares:
1. **Workspace sidebar (`GlobalSettingsPanel.tsx`):** reemplazar el campo de texto libre "Cliente" por un `<Select>` que lista las áreas de la org del proceso. El admin puede asignar/cambiar el área de un proceso desde aquí.
2. **WorkspacePage.tsx:** reemplazar la edición inline de `clientName` en el topbar por un display de solo lectura del `areaName`.
3. **OrganizationDetailPage.tsx:** agregar un tercer tab "Áreas" con CRUD completo — crear, renombrar, eliminar (con confirmación y conteo de procesos afectados).
Al terminar esta etapa, `area_id` es un campo vivo: se puede asignar desde el workspace y administrar desde el panel de admin.
---
## Contexto del código actual
Leer en su totalidad antes de modificar:
- `src/features/workspace/GlobalSettingsPanel.tsx` — campo "Cliente" con `localClient` state, `Input` editable para admin, `<p>` read-only para cliente.
- `src/features/workspace/WorkspacePage.tsx` — bloque "Cliente — editable inline" en el topbar (líneas ~254-276), función `saveField('client')` (línea ~125).
- `src/features/admin/OrganizationDetailPage.tsx` — dos tabs existentes: Procesos y Usuarios. Usa `Sheet` y `Select` de shadcn ya importados.
- `src/persistence/supabase/area-repo.ts` — CRUD completo ya implementado en Etapa 2. Función relevante: `supabaseAreaRepo.getByOrg(orgId)`.
- `src/domain/types.ts``Process` ya tiene `areaId: string | null` y `areaName?: string | null`.
- `src/persistence/supabase/process-repo.ts``updateEditable()` ya incluye `area_id: process.areaId`.
**Estado del store:** `updateProcess` en `process-store.ts` acepta updates parciales — mergea el parcial con el proceso actual y llama `updateEditable()`. No es necesario modificar el store.
---
## Parte A: `GlobalSettingsPanel.tsx` — selector de área
### Cambios de estado
**Eliminar:**
```typescript
const [localClient, setLocalClient] = useState('')
```
**Agregar:**
```typescript
const [localAreaId, setLocalAreaId] = useState<string>('') // '' = sin área (null en DB)
const [areas, setAreas] = useState<Area[]>([])
const [areasLoading, setAreasLoading] = useState(false)
```
Usar `''` (string vacío) como sentinel para `null` — el `<Select>` de shadcn maneja strings.
### Imports a agregar
```typescript
import { supabaseAreaRepo } from '@/persistence/supabase/area-repo'
import type { Area } from '@/domain/types'
```
### Actualizar el `useEffect` principal
En el `useEffect` que depende de `[currentProcess]`, reemplazar la línea `setLocalClient(currentProcess.clientName)` por:
```typescript
setLocalAreaId(currentProcess.areaId ?? '')
// Cargar áreas de la org del proceso
if (currentProcess.orgId) {
setAreasLoading(true)
supabaseAreaRepo.getByOrg(currentProcess.orgId)
.then(setAreas)
.catch(() => setAreas([]))
.finally(() => setAreasLoading(false))
} else {
setAreas([])
}
```
### Actualizar `handleSave`
**Eliminar:**
```typescript
clientName: localClient.trim(),
```
**Agregar:**
```typescript
areaId: localAreaId === '' ? null : localAreaId,
```
El campo `clientName` ya no se pasa en `handleSave` — el store conservará el valor existente en el proceso (el campo persiste en DB vía el objeto actual del proceso, sin cambios desde esta pantalla).
### Reemplazar el bloque JSX del campo "Cliente"
**Antes (campo texto libre "Cliente"):**
```tsx
<div className="space-y-1.5">
<Label className="text-xs font-medium">Cliente</Label>
{isClientRole ? (
<p className="text-sm text-slate-600 h-8 flex items-center px-1">{localClient || '—'}</p>
) : (
<Input
id="client-name"
value={localClient}
onChange={(e) => { setLocalClient(e.target.value); markDirty() }}
placeholder="ej. Empresa ABC"
className="h-8 text-sm"
/>
)}
</div>
```
**Después (selector de área):**
```tsx
<div className="space-y-1.5">
<Label className="text-xs font-medium">Área</Label>
{areasLoading ? (
<div className="h-8 bg-slate-100 animate-pulse rounded-md" />
) : !currentProcess.orgId ? (
<p className="text-sm text-slate-400 h-8 flex items-center px-1 italic">Sin organización asignada</p>
) : isClientRole && user?.platformRole === 'client_viewer' ? (
<p className="text-sm text-slate-600 h-8 flex items-center px-1">{currentProcess.areaName || '—'}</p>
) : (
<Select
value={localAreaId}
onValueChange={(v) => { setLocalAreaId(v); markDirty() }}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Sin área" />
</SelectTrigger>
<SelectContent>
<SelectItem value="" className="text-xs text-slate-400 italic">Sin área</SelectItem>
{areas.map((a) => (
<SelectItem key={a.id} value={a.id} className="text-xs">{a.name}</SelectItem>
))}
{areas.length === 0 && (
<div className="px-2 py-1.5 text-xs text-slate-400">
No hay áreas en esta organización
</div>
)}
</SelectContent>
</Select>
)}
</div>
```
**Notas:**
- `client_viewer` nunca llega al workspace (redirigido en WorkspacePage). La condición `isClientRole && user?.platformRole === 'client_viewer'` es por seguridad.
- `client_editor` sí puede seleccionar área — la RLS de `areas_select` le retorna las áreas de su org.
- Si `currentProcess.orgId` es null (edge case post-migración), mostrar texto descriptivo en lugar del dropdown.
- `Select` ya está importado en el archivo.
### Verificar que `Select` y sus sub-componentes estén importados
Ya están importados en `GlobalSettingsPanel.tsx` (`Select`, `SelectContent`, `SelectItem`, `SelectTrigger`, `SelectValue`). Confirmar antes de agregar duplicados.
---
## Parte B: `WorkspacePage.tsx` — display de areaName en topbar
### Qué cambiar
Reemplazar el bloque "Cliente — editable inline" (~líneas 254-276) en el topbar.
**Antes (edición inline de clientName):**
```tsx
{/* Cliente — editable inline solo para roles internos */}
{user?.platformRole === 'client_editor' ? (
currentProcess.clientName && (
<p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
)
) : editingField === 'client' ? (
<input
autoFocus
className="text-xs text-slate-400 bg-transparent border-b border-[#F59845] outline-none w-full"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => saveField('client')}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); saveField('client') }
if (e.key === 'Escape') setEditingField(null)
}}
placeholder="Agregar cliente…"
/>
) : (
<div
className="flex items-center gap-1 group cursor-text"
onClick={() => { setEditValue(currentProcess.clientName ?? ''); setEditingField('client') }}
>
{currentProcess.clientName
? <p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
: <p className="text-xs text-slate-300 truncate italic">Agregar cliente</p>
}
{savedField === 'client'
? <Check className="h-3 w-3 text-[#F59845] shrink-0" />
: <Pencil className="h-2.5 w-2.5 text-slate-300 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
}
</div>
)}
```
**Después (display de areaName, solo lectura):**
```tsx
{/* Área — display de solo lectura (editable desde el panel de configuración) */}
{currentProcess.areaName && (
<p className="text-xs text-slate-400 truncate">{currentProcess.areaName}</p>
)}
```
### Lo que NO cambia en WorkspacePage.tsx
- La función `saveField` — puede quedar con el caso `'client'` como código muerto, no eliminar.
- El tipo de `editingField` — no es necesario actualizar si es genérico.
- El bloque de edición inline del **nombre** del proceso — sin cambios.
- Todo el resto del archivo.
**Restricción explícita:** NO eliminar la función `saveField` ni el estado `editingField`. Solo reemplazar el bloque JSX del cliente. El `saveField('client')` queda como código muerto — menos riesgo que eliminar y romper algo.
---
## Parte C: `OrganizationDetailPage.tsx` — CRUD de áreas
### Estructura de estado a agregar
```typescript
// Áreas de la org
const [areas, setAreas] = useState<AreaWithCount[]>([])
const [areasLoading, setAreasLoading] = useState(false)
// Sheet crear/editar área
const [areaSheetOpen, setAreaSheetOpen] = useState(false)
const [editingAreaId, setEditingAreaId] = useState<string | null>(null) // null = crear nuevo
const [areaNameInput, setAreaNameInput] = useState('')
const [savingArea, setSavingArea] = useState(false)
const [areaFormError, setAreaFormError] = useState<string | null>(null)
// Confirmación de delete
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const [deletingAreaId, setDeletingAreaId] = useState<string | null>(null)
```
### Interfaces locales a agregar (top del archivo)
```typescript
interface AreaWithCount {
id: string
name: string
description: string | null
processCount: number
}
```
Y extender `OrgProcess` con `area_id`:
```typescript
interface OrgProcess {
id: string
name: string
client: string | null
area_id: string | null // 🆕 para calcular processCount por área
updated_at: string
}
```
### Import a agregar
```typescript
import { supabaseAreaRepo } from '@/persistence/supabase/area-repo'
import { FolderOpen, Plus, Pencil, Trash2 } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
```
Verificar qué ya está importado antes de agregar duplicados.
### Actualizar `fetchDetail`
Extender el `Promise.all` para incluir la carga de áreas:
```typescript
const fetchDetail = async () => {
const [orgRes, procRes, userRes] = await Promise.all([
supabase.from('organizations').select('name').eq('id', orgId).single(),
supabase
.from('processes')
.select('id, name, client, area_id, updated_at') // ← agregar area_id
.eq('org_id', orgId)
.order('updated_at', { ascending: false }),
supabase.from('users').select('id, name, company, platform_role, avatar_url').eq('org_id', orgId).order('name'),
])
if (orgRes.data) setOrgName((orgRes.data as { name: string }).name)
const loadedProcesses = (procRes.data as OrgProcess[] | null) ?? []
setProcesses(loadedProcesses)
setUsers((userRes.data as OrgUser[] | null) ?? [])
setLoading(false)
// Cargar áreas y calcular conteo de procesos
setAreasLoading(true)
try {
const rawAreas = await supabaseAreaRepo.getByOrg(orgId)
const withCount: AreaWithCount[] = rawAreas.map((a) => ({
id: a.id,
name: a.name,
description: a.description,
processCount: loadedProcesses.filter((p) => p.area_id === a.id).length,
}))
setAreas(withCount)
} catch {
setAreas([])
} finally {
setAreasLoading(false)
}
}
```
**Nota:** las áreas se cargan después de los procesos para poder calcular el conteo con un solo fetch.
### Funciones CRUD de áreas
```typescript
const openCreateArea = () => {
setEditingAreaId(null)
setAreaNameInput('')
setAreaFormError(null)
setAreaSheetOpen(true)
}
const openEditArea = (area: AreaWithCount) => {
setEditingAreaId(area.id)
setAreaNameInput(area.name)
setAreaFormError(null)
setAreaSheetOpen(true)
}
const handleSaveArea = async () => {
const name = areaNameInput.trim()
if (!name) { setAreaFormError('El nombre del área es requerido'); return }
setSavingArea(true)
setAreaFormError(null)
try {
if (editingAreaId) {
await supabaseAreaRepo.update(editingAreaId, { name })
} else {
await supabaseAreaRepo.create({ orgId, name })
}
setAreaSheetOpen(false)
void fetchDetail()
} catch (e) {
setAreaFormError(e instanceof Error ? e.message : 'Error al guardar el área')
} finally {
setSavingArea(false)
}
}
const handleDeleteArea = async (areaId: string) => {
setDeletingAreaId(areaId)
try {
await supabaseAreaRepo.delete(areaId)
setDeleteConfirmId(null)
void fetchDetail()
} catch (e) {
console.error('Error eliminando área:', e)
} finally {
setDeletingAreaId(null)
}
}
```
### Agregar tercer tab "Áreas"
En `<TabsList>`, agregar después del tab de Usuarios:
```tsx
<TabsTrigger value="areas">
<FolderOpen className="h-4 w-4 mr-1.5" />
Áreas ({areas.length})
</TabsTrigger>
```
Y el contenido del tab:
```tsx
{/* ─── Tab: Áreas ──────────────────────────────────────────────── */}
<TabsContent value="areas">
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-slate-500">
{areasLoading
? 'Cargando áreas…'
: areas.length === 0
? 'No hay áreas en esta organización.'
: `${areas.length} área${areas.length !== 1 ? 's' : ''}.`}
</p>
<Button size="sm" variant="outline" onClick={openCreateArea}>
<Plus className="h-4 w-4 mr-1.5" />
Nueva área
</Button>
</div>
{areasLoading ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : areas.length === 0 ? (
<EmptyState
icon={<FolderOpen className="h-10 w-10 text-slate-300" />}
message="Las áreas agrupan procesos dentro de una organización (ej: Operaciones, RR.HH., Finanzas)."
/>
) : (
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Área</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Procesos</th>
<th className="py-2.5 px-4" />
</tr>
</thead>
<tbody>
{areas.map((area) => (
<tr key={area.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
<td className="py-3 px-4 font-medium text-slate-900">{area.name}</td>
<td className="py-3 px-4 text-slate-500">
{area.processCount} {area.processCount === 1 ? 'proceso' : 'procesos'}
</td>
<td className="py-3 px-4">
{deleteConfirmId === area.id ? (
/* Confirmación inline */
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-slate-500">
{area.processCount > 0
? `${area.processCount} proceso${area.processCount !== 1 ? 's' : ''} quedarán sin área.`
: '¿Eliminar esta área?'}
</span>
<Button
size="sm"
variant="destructive"
className="h-7 text-xs px-2"
disabled={deletingAreaId === area.id}
onClick={() => handleDeleteArea(area.id)}
>
{deletingAreaId === area.id ? 'Eliminando…' : 'Confirmar'}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs px-2"
onClick={() => setDeleteConfirmId(null)}
>
Cancelar
</Button>
</div>
) : (
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-slate-400 hover:text-slate-700"
onClick={() => openEditArea(area)}
title="Renombrar área"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-slate-400 hover:text-red-600"
onClick={() => setDeleteConfirmId(area.id)}
title="Eliminar área"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
```
### Agregar Sheet para crear/editar área
Después del Sheet existente de "Asignar proceso", agregar:
```tsx
{/* ─── Sheet: Crear/editar área ────────────────────────────────── */}
<Sheet open={areaSheetOpen} onOpenChange={setAreaSheetOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>
{editingAreaId ? 'Renombrar área' : `Nueva área en ${orgName}`}
</SheetTitle>
</SheetHeader>
<div className="mt-5 flex flex-col gap-4">
<div className="space-y-1.5">
<Label htmlFor="area-name" className="text-sm font-medium">
Nombre del área
</Label>
<Input
id="area-name"
value={areaNameInput}
onChange={(e) => { setAreaNameInput(e.target.value); setAreaFormError(null) }}
placeholder="ej. Operaciones, RR.HH., Finanzas"
className="h-9"
onKeyDown={(e) => { if (e.key === 'Enter') void handleSaveArea() }}
autoFocus
/>
{areaFormError && (
<p className="text-xs text-red-600">{areaFormError}</p>
)}
</div>
<SheetFooter>
<Button onClick={handleSaveArea} disabled={savingArea || !areaNameInput.trim()}>
{savingArea ? 'Guardando…' : editingAreaId ? 'Guardar cambios' : 'Crear área'}
</Button>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
```
---
## Lo que NO cambia en esta etapa
- **`src/domain/types.ts`** — no tocar (ya tiene `areaId`, `areaName`)
- **`src/persistence/supabase/area-repo.ts`** — no tocar (CRUD ya implementado)
- **`src/persistence/supabase/process-repo.ts`** — no tocar
- **`src/store/process-store.ts`** — no tocar
- **`src/features/library/LibraryPage.tsx`** — no tocar
- **`src/features/settings/SettingsPage.tsx`** — no tocar
- **Cualquier otro archivo** — sin cambios
**Campos `clientName` y `groupId` en el dominio:** todavía coexisten. En GlobalSettingsPanel, el campo "Cliente" se reemplaza por "Área". El `clientName` sigue en el proceso como dato histórico, solo deja de ser editable desde esta pantalla. Se eliminará del dominio en la migración de limpieza (futura Etapa de cleanup).
---
## Criterios de validación
### Funcionales (validación visual mandatoria del director antes del OK formal)
**GlobalSettingsPanel (workspace sidebar):**
- [ ] El campo "Área" muestra un Select con las áreas de la org del proceso
- [ ] La opción "Sin área" aparece primero en el dropdown
- [ ] Seleccionar un área y guardar persiste `area_id` en el proceso (verificar en Supabase o en el Network tab)
- [ ] Al recargar la página, el área seleccionada se muestra correctamente
- [ ] Si el proceso no tiene `orgId` (edge case), el field muestra "Sin organización asignada" en lugar del dropdown
- [ ] Si la org no tiene áreas, el dropdown muestra "No hay áreas en esta organización"
- [ ] El campo "Cliente" ya no aparece en el panel
**WorkspacePage (topbar):**
- [ ] Si el proceso tiene `areaName`, se muestra como texto de solo lectura bajo el nombre del proceso
- [ ] Si no tiene `areaName`, la segunda línea no aparece (no muestra texto vacío ni placeholder)
- [ ] Ya no hay edición inline de cliente en el topbar
**OrganizationDetailPage (admin):**
- [ ] Existe un tercer tab "Áreas" en la página de detalle de organización
- [ ] El tab lista las áreas con nombre y conteo de procesos
- [ ] "Nueva área" abre un sheet con un input de nombre y botón Crear
- [ ] Crear un área nueva aparece en la lista (sin recargar la página manualmente)
- [ ] El botón de lápiz abre el sheet con el nombre actual pre-cargado para renombrar
- [ ] El botón de papelera muestra confirmación inline con el conteo de procesos afectados
- [ ] Confirmar delete elimina el área y la lista se actualiza
- [ ] Cancelar delete cierra la confirmación sin eliminar
### Técnicos
- [ ] `npm run build` sin errores TypeScript
- [ ] `npm run test` sin regresiones (596 tests — esta etapa no agrega tests, pero no puede romper existentes)
- [ ] No hay `any` introducido sin justificación
---
## Restricciones explícitas
- **NO eliminar** `clientName` ni `groupId` del dominio ni de los repositorios — sigue coexistiendo
- **NO modificar** `process-store.ts` — el store ya maneja `areaId` vía partial updates
- **NO modificar** `src/domain/types.ts` — ya tiene los campos necesarios desde Etapa 2
- **NO modificar** `area-repo.ts` — no cambiar el repo existente
- **NO agregar** nuevas dependencias — todo con el stack existente
- **NO crear** archivos de test en esta etapa — el esfuerzo de test va en Etapa 6
---
## Archivos autorizados a modificar
```
src/
├── features/
│ ├── workspace/
│ │ ├── GlobalSettingsPanel.tsx ← Parte A: reemplazar campo Cliente → selector Área
│ │ └── WorkspacePage.tsx ← Parte B: reemplazar inline edit clientName → display areaName
│ └── admin/
│ └── OrganizationDetailPage.tsx ← Parte C: agregar tab Áreas + CRUD
```
Ningún otro archivo debe modificarse.
---
## Commits esperados
```
feat(workspace): selector de área en GlobalSettingsPanel reemplaza campo libre "Cliente"
feat(workspace): topbar muestra areaName en lugar de clientName inline-editable
feat(admin): CRUD de áreas en OrganizationDetailPage — tab Áreas con crear/renombrar/eliminar
```
---
## Reporte esperado
1. Confirmar que el campo "Cliente" en `GlobalSettingsPanel` ya no existe y fue reemplazado por el selector de área
2. Confirmar que el topbar de WorkspacePage ya no tiene edición inline de clientName
3. Confirmar que `OrganizationDetailPage` tiene un tercer tab "Áreas" funcional
4. Lista compacta de archivos modificados con resumen de cambios
5. Resultado de `npm run build` y `npm run test` (con conteo de tests)
6. **Solicitar validación visual del director** — describir el estado de la UI en los tres lugares para que el director valide en la app antes del OK formal

View File

@@ -0,0 +1,343 @@
# Sprint 8 — Etapa 5: client_editor puede importar BPMN
> **Tipo:** DB + UI — habilitar flujo de importación para rol cliente editor
> **Rama:** main
> **Estimación:** 1.5 horas
> **Dependencias:** Etapas 14 completadas ✅
> (tabla `areas` y columna `org_id` en processes ya existen, `isClientRole` ya implementado en workspace)
---
## Objetivo
Habilitar que un usuario con rol `client_editor` pueda importar archivos BPMN propios. Actualmente la `ImportPage` redirige a todos los roles cliente a `/`. Con esta etapa, `client_editor` pasa a ser un ciudadano de primera clase del flujo de importación: puede subir un BPMN, el proceso se asigna automáticamente a su org, y puede trabajar desde el workspace como lo hace InQuality.
`client_viewer` **no** obtiene acceso de importación — eso no cambia.
---
## Contexto del código actual
Leer estos archivos antes de modificar:
- `src/features/import/ImportPage.tsx` — tiene dos guards que bloquean a client_editor:
1. `useEffect` con `allowed = ['platform_admin', 'member']` → redirige a `/`
2. `if (isClientRole) return null` antes del return final → nunca llega al JSX
Además, el proceso se crea con `orgId: null`, lo que rompe las RLS.
- `src/features/library/LibraryPage.tsx` — el botón "Importar BPMN" del header está envuelto en `{!isClientRole && ...}` (líneas ~841-860). El empty state de la vista cliente muestra "Contactá a tu administrador" sin botón de importar para nadie.
- `supabase/migrations/015_client_roles_and_rls.sql` — la política `insert_processes` permite solo `platform_admin` y `member`. La línea de comentario lo dice explícitamente: `-- client_editor y client_viewer NO pueden crear procesos nuevos`.
---
## Parte 1: Migration 018
Crear `supabase/migrations/018_client_editor_insert.sql`.
El único cambio de esta migración: extender la política `insert_processes` para incluir a `client_editor` cuando inserta en su propia org.
```sql
-- Permite que client_editor inserte procesos en su propia org
-- Los demás roles mantienen sus permisos actuales
DROP POLICY IF EXISTS "insert_processes" ON public.processes;
CREATE POLICY "insert_processes" ON public.processes
FOR INSERT WITH CHECK (
public.is_platform_admin()
OR public.current_platform_role() = 'member'
OR (
public.current_platform_role() = 'client_editor'
AND org_id = public.current_org()
)
-- client_viewer y guest siguen sin poder crear procesos
);
```
**Verificar que las políticas de actividades y gateways ya permiten a client_editor:**
Buscar en `015_client_roles_and_rls.sql`:
- `activities_insert`: usa `NOT IN ('client_viewer', 'guest')` → client_editor puede ✅
- `gateways_write`: usa `NOT IN ('client_viewer', 'guest')` → client_editor puede ✅
Si alguna de esas dos NO incluye a client_editor, corregir en esta misma migración. Verificar antes de cerrar el archivo.
### Aplicar la migración
Aplicar en Supabase y verificar:
```sql
-- Verificar que la nueva política existe
SELECT policyname, cmd, qual
FROM pg_policies
WHERE schemaname = 'public' AND tablename = 'processes';
-- Esperado: ver "insert_processes" con la nueva definición que incluye client_editor
```
---
## Parte 2: `ImportPage.tsx`
### 2A — Eliminar los dos guards que bloquean a client_editor
**Guard 1 — cambiar la lista de roles permitidos en el `useEffect`:**
```typescript
// Antes:
const allowed = ['platform_admin', 'member']
if (!allowed.includes(user.platformRole)) {
void navigate({ to: '/' })
}
// Después:
const allowed = ['platform_admin', 'member', 'client_editor']
if (!allowed.includes(user.platformRole)) {
void navigate({ to: '/' })
}
```
**Guard 2 — reemplazar el bloqueo total por uno solo para client_viewer:**
```typescript
// Antes (bloquea a todos los client roles):
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
if (isClientRole) return null
// Después (solo bloquea client_viewer):
if (user?.platformRole === 'client_viewer') return null
```
### 2B — Asignar `orgId` del usuario en el proceso creado
Al construir el objeto `process` dentro de `processFile`, el campo `orgId` actualmente es `null`. Para que la RLS de `insert_processes` valide `org_id = current_org()`, el proceso insertado debe tener el `org_id` del usuario.
```typescript
// Antes:
const process: Process = {
// ...
orgId: null,
// ...
}
// Después:
const process: Process = {
// ...
orgId: user!.orgId, // ← org_id del usuario autenticado; null solo si no tiene org (no debería pasar para client_editor)
// ...
}
```
Verificar que `user.orgId` está disponible en el tipo `User` del AuthContext (debería estar — se pobló en Sprint 7).
### 2C — Ocultar los procesos de ejemplo para client_editor
Los "procesos de ejemplo" (`SAMPLE_PROCESSES`) son demostraciones internas de InQuality. Un client_editor no debería importar datos de prueba de InQuality en su propio espacio.
En el JSX, la sección de ejemplos está bajo el drop zone. Envolverla en una condición:
```tsx
// Antes: siempre visible
<div className="mt-8 w-full max-w-lg">
<p ...>O cargá un proceso de ejemplo con un click</p>
<div className="grid grid-cols-3 gap-3">
{SAMPLE_PROCESSES.map(...)}
</div>
</div>
// Después: solo visible para InQuality (platform_admin y member)
{user?.platformRole !== 'client_editor' && (
<div className="mt-8 w-full max-w-lg">
<p ...>O cargá un proceso de ejemplo con un click</p>
<div className="grid grid-cols-3 gap-3">
{SAMPLE_PROCESSES.map(...)}
</div>
</div>
)}
```
### 2D — Corregir texto de marca en ImportPage
El header tiene texto desactualizado que viola las reglas de marca del producto:
```tsx
// Antes (incorrecto — nombre viejo del producto):
<div className="inline-flex items-center gap-2 ...">
<FlaskConical className="h-3.5 w-3.5" />
Process Cost Platform · MVP Fase 0
</div>
// Después:
<div className="inline-flex items-center gap-2 ...">
<FlaskConical className="h-3.5 w-3.5" />
InQ ROI
</div>
```
El tagline del final de la página también está desactualizado:
```tsx
// Antes:
<p className="mt-8 text-xs text-slate-300">
100% en tu navegador · Sin backend · Sin registro · Datos guardados localmente
</p>
// Después: eliminar este párrafo (el producto ya tiene backend y registro)
```
---
## Parte 3: `LibraryPage.tsx`
### 3A — Botón "Importar BPMN" en el header visible para client_editor
El botón en el header de LibraryPage (líneas ~841-860) está condicionado con `!isClientRole`, lo que lo oculta para todos los roles cliente. Cambiar para mostrarlo a `client_editor`:
```tsx
// Antes:
{!isClientRole && (
<button
onClick={handleImport}
className="..."
style={{ background: '#F59845' }}
>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
)}
// Después:
{user?.platformRole !== 'client_viewer' && (
<button
onClick={handleImport}
className="..."
style={{ background: '#F59845' }}
>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
)}
```
### 3B — Empty state diferenciado para client_editor vs client_viewer
En `HomeView`, el empty state para roles cliente (cuando `groups.length === 0 && processes.length === 0`) actualmente muestra "Contactá a tu administrador" para todos. Diferenciarlo:
```tsx
// Antes (igual para todos los client roles):
if (isClientRole) {
return (
<div ...>
<Building2 ... />
<p>No tenés procesos asignados aún</p>
<p>Contactá a tu administrador para que te asigne acceso</p>
</div>
)
}
// Después (diferenciado):
if (isClientRole) {
if (user?.platformRole === 'client_editor') {
return (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<Building2 className="h-16 w-16 text-muted-foreground/30" />
<p className="text-base font-medium">Tu biblioteca está vacía</p>
<p className="text-[13px] text-muted-foreground">
Importá tu primer BPMN para empezar a cuantificar costos
</p>
<button
onClick={() => onImport()}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
style={{ background: '#F59845' }}
>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
</div>
)
}
// client_viewer: mantener mensaje existente
return (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<Building2 className="h-16 w-16 text-muted-foreground/30" />
<p className="text-base font-medium">No tenés procesos asignados aún</p>
<p className="text-[13px] text-muted-foreground">Contactá a tu administrador para que te asigne acceso</p>
</div>
)
}
```
`user` está disponible en el scope de `HomeView` como prop (o via `useAuth()` — verificar cuál aplica).
---
## Lo que NO cambia en esta etapa
- **`process-repo.ts`** — no tocar. `updateEditable()` no necesita `bpmn_xml` para este flujo.
- **`process-store.ts`** — no tocar.
- **`WorkspacePage.tsx`** — sin cambios. client_editor ya tiene acceso al workspace desde Sprint 7 (la RLS de `update_processes` ya lo cubre).
- **`GlobalSettingsPanel.tsx`** — sin cambios. El selector de área ya funciona para client_editor desde Etapa 4.
- **Rol `client_viewer`** — sigue sin acceso a importación en todo momento.
---
## Criterios de validación
### Técnicos
- [ ] Migración 018 creada y aplicada en Supabase
- [ ] `insert_processes` en la DB incluye la cláusula de client_editor (verificar con `pg_policies`)
- [ ] `npm run build` sin errores TypeScript
- [ ] `npm run test` sin regresiones (596 tests)
### Funcionales (validación visual mandatoria del director)
- [ ] Loguear como `client_editor``/library` → botón "Importar BPMN" visible en el header
- [ ] Hacer click en "Importar BPMN" → lleva a `/import` (no redirige a `/`)
- [ ] `/import` muestra el drop zone; la sección de "procesos de ejemplo" NO aparece
- [ ] Subir un archivo BPMN real → proceso creado → workspace abre correctamente
- [ ] En Supabase: verificar que el proceso creado tiene `org_id` = org del client_editor (no null)
- [ ] Loguear como `client_viewer``/library` → botón "Importar BPMN" NO visible
- [ ] Si client_viewer navega manualmente a `/import` → redirige a `/`
- [ ] La biblioteca vacía de client_editor muestra botón de importar; la de client_viewer muestra "Contactá a tu administrador"
---
## Restricciones explícitas
- **NO** tocar políticas RLS de tablas distintas a `processes` (activities y gateways ya permiten client_editor)
- **NO** crear procesos de muestra en la DB para client_editor — el flujo es importar BPMNs reales
- **NO** modificar el workspace ni el reporte — client_editor ya puede usarlos
- **NO** agregar tests en esta etapa
---
## Archivos autorizados a modificar/crear
```
supabase/migrations/
└── 018_client_editor_insert.sql ← crear y aplicar
src/features/
├── import/
│ └── ImportPage.tsx ← guards + orgId + ejemplos + texto marca
└── library/
└── LibraryPage.tsx ← botón header + empty state diferenciado
```
---
## Commits esperados
```
feat(db): migración 018 — INSERT policy para client_editor en processes
feat(import): client_editor puede importar BPMN; orgId asignado automáticamente
feat(library): botón Importar BPMN visible para client_editor; empty state diferenciado
fix(import): corregir texto de marca — "Process Cost Platform" → "InQ ROI"
```
---
## Reporte esperado
1. Contenido exacto de `018_client_editor_insert.sql`
2. Resultado de la query de verificación en `pg_policies` para `insert_processes`
3. Lista de cambios en `ImportPage.tsx` (resumen compacto)
4. Lista de cambios en `LibraryPage.tsx` (resumen compacto)
5. `npm run build` y `npm run test` (con conteo)
6. **Solicitar validación visual del director** — describir el estado de la UI para client_editor antes del OK formal

View File

@@ -2,12 +2,15 @@ import { RouterProvider } from '@tanstack/react-router'
import { router } from './router' import { router } from './router'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { AuthProvider } from '@/auth/AuthContext' import { AuthProvider } from '@/auth/AuthContext'
import { TooltipProvider } from '@/components/ui/tooltip'
export function App() { export function App() {
return ( return (
<AuthProvider> <AuthProvider>
<RouterProvider router={router} /> <TooltipProvider>
<Toaster /> <RouterProvider router={router} />
<Toaster />
</TooltipProvider>
</AuthProvider> </AuthProvider>
) )
} }

View File

@@ -1,39 +1,190 @@
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react' import { createContext, useContext, useEffect, useRef, useState, useCallback, type ReactNode } from 'react'
import { SupabaseAuthService } from './SupabaseAuthService' import { SupabaseAuthService } from './SupabaseAuthService'
import { supabase } from '@/lib/supabase'
import type { AuthUser } from './types' import type { AuthUser } from './types'
interface AuthContextValue { interface AuthContextValue {
user: AuthUser | null user: AuthUser | null
loading: boolean loading: boolean
isProfileLoaded: boolean
signIn: () => Promise<void> signIn: () => Promise<void>
signInWithEmailPassword: (email: string, password: string) => Promise<{ error: string | null }>
signOut: () => Promise<void> signOut: () => Promise<void>
updateUser: (updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => void
pendingPasswordReset: boolean
clearPasswordReset: () => void
} }
const AuthContext = createContext<AuthContextValue | null>(null) const AuthContext = createContext<AuthContextValue | null>(null)
const authService = new SupabaseAuthService() const authService = new SupabaseAuthService()
// Lee el usuario almacenado en localStorage SIN hacer llamadas de red.
// Supabase v2 usa la clave sb-{project_ref}-auth-token.
// Permite renderizar la app inmediatamente sin esperar la verificación async de sesión.
function getUserFromStorage(): AuthUser | null {
try {
const key = Object.keys(localStorage).find(
(k) => k.startsWith('sb-') && k.endsWith('-auth-token')
)
if (!key) return null
const stored = JSON.parse(localStorage.getItem(key) ?? 'null')
const u = stored?.user
if (!u?.id) return null
const email = (u.email as string) ?? ''
return {
id: u.id as string,
email,
name: (u.user_metadata?.full_name as string | undefined) || email,
avatarUrl: u.user_metadata?.avatar_url as string | undefined,
platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest',
}
} catch {
return null
}
}
// Detecta si la URL tiene un callback de OAuth (token en el hash o code en query).
// En ese caso NO usar localStorage — dejar que Supabase procese el hash primero.
function hasOAuthCallback(): boolean {
const hash = window.location.hash
const search = window.location.search
return (
hash.includes('access_token') ||
hash.includes('refresh_token') ||
search.includes('code=')
)
}
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null) // Si hay un OAuth callback en la URL, no leer localStorage (el token aún no está ahí).
const [loading, setLoading] = useState(true) // Supabase lo procesará en onAuthStateChange y actualizará el estado.
const isOAuthRedirect = hasOAuthCallback()
const [user, setUser] = useState<AuthUser | null>(
isOAuthRedirect ? null : getUserFromStorage()
)
// Si es un OAuth redirect, mantener loading=true hasta que Supabase procese el token.
const [loading, setLoading] = useState(isOAuthRedirect)
const [pendingPasswordReset, setPendingPasswordReset] = useState(false)
const [isProfileLoaded, setIsProfileLoaded] = useState(false)
// Ref para comparar id entre eventos de auth y NO resetear isProfileLoaded en eventos
// como TOKEN_REFRESHED que llegan con el mismo usuario. Sin esto, si el evento llega
// después de que syncProfileFromDB completó, isProfileLoaded queda en false para siempre.
const lastSyncedUserIdRef = useRef<string | null>(
isOAuthRedirect ? null : (getUserFromStorage()?.id ?? null)
)
useEffect(() => { useEffect(() => {
// Verificar y sincronizar con Supabase en background.
// Si la sesión expiró o fue revocada, onAuthStateChange llamará callback(null)
// y el router redirigirá a /login.
const unsubscribe = authService.onAuthStateChange((u) => { const unsubscribe = authService.onAuthStateChange((u) => {
const prevId = lastSyncedUserIdRef.current
lastSyncedUserIdRef.current = u?.id ?? null
setUser(u) setUser(u)
setLoading(false) setLoading(false)
if (!u) {
// Logout → nada que sincronizar
setIsProfileLoaded(true)
} else if (u.id !== prevId) {
// Usuario diferente → requiere nueva sincronización con DB
setIsProfileLoaded(false)
}
// Mismo usuario (TOKEN_REFRESHED, INITIAL_SESSION, etc.) → preservar isProfileLoaded
}) })
return unsubscribe return () => unsubscribe()
}, []) }, [])
// Suscripción directa para PASSWORD_RECOVERY (no pasa por authService para evitar
// mezclar el flujo normal con el de reset de contraseña).
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange((event) => {
if (event === 'PASSWORD_RECOVERY') {
setPendingPasswordReset(true)
}
})
return () => subscription.unsubscribe()
}, [])
// Sincronización con tabla users FUERA del lock de auth.
// Se ejecuta al cambiar de usuario (login/logout), NO en cada updateUser().
// user?.id es primitivo estable: no cambia cuando se edita nombre o empresa.
useEffect(() => {
if (!user?.id) return
const userId = user.id
const capturedUser = user
// 6s timeout: si el servidor no responde, la señal de abort cancela las queries
// y libera el processLock de Supabase antes de que bloquee loads de biblioteca/workspace.
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 6000)
;(async () => {
try {
// Upsert idempotente: crea la fila si no existe (primer login),
// ignoreDuplicates evita sobreescribir name/company editados posteriormente.
await authService.upsertUserOnLogin(capturedUser, controller.signal)
// Leer platform_role, org_id, name y company desde la tabla (sobrescriben la heurística de email).
const profile = await authService.syncProfileFromDB(userId, controller.signal)
if (profile) {
setUser((prev) =>
prev?.id === userId
? {
...prev,
name: profile.name || prev.name,
company: profile.company,
platformRole: profile.platformRole,
orgId: profile.orgId,
}
: prev
)
}
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
console.error('Error al sincronizar perfil:', err)
} finally {
clearTimeout(timeoutId)
setIsProfileLoaded(true)
}
})()
return () => {
controller.abort()
clearTimeout(timeoutId)
}
}, [user?.id]) // eslint-disable-line react-hooks/exhaustive-deps
const signIn = useCallback(() => authService.signInWithGoogle(), []) const signIn = useCallback(() => authService.signInWithGoogle(), [])
const signInWithEmailPassword = useCallback(
(email: string, password: string) => authService.signInWithEmailPassword(email, password),
[]
)
const signOut = useCallback(async () => { const signOut = useCallback(async () => {
await authService.signOut() await authService.signOut()
setUser(null) setUser(null)
setPendingPasswordReset(false)
}, []) }, [])
const updateUser = useCallback((updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => {
setUser((prev) => prev ? { ...prev, ...updates } : prev)
}, [])
const clearPasswordReset = useCallback(() => setPendingPasswordReset(false), [])
return ( return (
<AuthContext.Provider value={{ user, loading, signIn, signOut }}> <AuthContext.Provider value={{
user,
loading,
isProfileLoaded,
signIn,
signInWithEmailPassword,
signOut,
updateUser,
pendingPasswordReset,
clearPasswordReset,
}}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )

View File

@@ -5,16 +5,30 @@ export class SupabaseAuthService implements AuthService {
async signInWithGoogle(): Promise<void> { async signInWithGoogle(): Promise<void> {
const { error } = await supabase.auth.signInWithOAuth({ const { error } = await supabase.auth.signInWithOAuth({
provider: 'google', provider: 'google',
options: { redirectTo: window.location.origin }, options: {
redirectTo: window.location.origin,
queryParams: {
prompt: 'select_account', // fuerza el selector de cuentas de Google, incluso con sesión activa
},
},
}) })
if (error) throw error if (error) throw error
} }
async signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }> {
const { error } = await supabase.auth.signInWithPassword({ email, password })
return { error: error?.message ?? null }
}
async signOut(): Promise<void> { async signOut(): Promise<void> {
const { error } = await supabase.auth.signOut() const { error } = await supabase.auth.signOut()
if (error) throw error if (error) throw error
} }
// Auditoría Sprint 5 Etapa 1: este getSession() NO se reemplaza por getSessionSafe()
// porque necesita el objeto session en sí (no es una barrera descartable) y getCurrentUser()
// no tiene ningún caller en la app hoy (AuthContext usa onAuthStateChange). Cambios de auth
// están fuera de scope de esta etapa — se documenta la decisión, no se toca el comportamiento.
async getCurrentUser(): Promise<AuthUser | null> { async getCurrentUser(): Promise<AuthUser | null> {
const { data: { session } } = await supabase.auth.getSession() const { data: { session } } = await supabase.auth.getSession()
if (!session?.user) return null if (!session?.user) return null
@@ -23,44 +37,28 @@ export class SupabaseAuthService implements AuthService {
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void { onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
const { data: { subscription } } = supabase.auth.onAuthStateChange( const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => { (event, session) => { // NO async — el lock de GoTrueClient está activo durante este callback
// Siempre llamar callback — incluso si falla algo interno
try { try {
// Token refresh silencioso — no disparar re-sync ni setLoading
if (event === 'TOKEN_REFRESHED' || event === 'USER_UPDATED') return
if (!session?.user) { if (!session?.user) {
callback(null) // INITIAL_SESSION null es transitorio: Supabase no pudo verificar la sesión
// (red lenta, cold start, timeout). Si hay sesión en localStorage, no forzar logout.
// Solo cerrar sesión en eventos explícitos (SIGNED_OUT, USER_DELETED, etc).
if (event !== 'INITIAL_SESSION') callback(null)
return return
} }
// PASSWORD_RECOVERY tiene sesión activa — incluir para que AuthContext establezca el usuario
if (event === 'SIGNED_IN') { // antes de redirigir a /reset-password. La señal de redirect se maneja en AuthContext.
const email = session.user.email ?? '' if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN' && event !== 'PASSWORD_RECOVERY') return
const role = email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest' // Llamar callback inmediatamente con datos básicos del token OAuth.
const { error: upsertError } = await supabase.from('users').upsert( // NO hacer queries a Supabase desde aquí — el lock de auth está activo (GoTrueClient.js L3213).
{ // El upsert y la lectura de platform_role/org_id ocurren en AuthContext fuera del lock.
id: session.user.id, callback(this.buildAuthUser(session.user))
name: session.user.user_metadata?.full_name ?? email,
avatar_url: session.user.user_metadata?.avatar_url ?? null,
platform_role: role,
},
{ onConflict: 'id', ignoreDuplicates: true }
)
if (upsertError) console.error('Error al crear usuario en DB:', upsertError)
}
const user = await this.buildAuthUser(session.user)
callback(user)
} catch (err) { } catch (err) {
console.error('Error en onAuthStateChange:', err) console.error('Error en onAuthStateChange:', err)
// Garantizar que loading se desbloquea incluso en error if (session?.user) callback(this.buildAuthUser(session.user))
if (session?.user) { else if (event !== 'INITIAL_SESSION') callback(null)
callback({
id: session.user.id,
email: session.user.email ?? '',
name: session.user.user_metadata?.full_name as string ?? session.user.email ?? '',
platformRole: 'guest',
})
} else {
callback(null)
}
} }
} }
) )
@@ -68,20 +66,49 @@ export class SupabaseAuthService implements AuthService {
return () => subscription.unsubscribe() return () => subscription.unsubscribe()
} }
private async buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): Promise<AuthUser> { // Leer platform_role, org_id, company y name desde la tabla users.
const email = supabaseUser.email ?? '' // Sobrescribe la heurística de email de buildAuthUser con el valor real de DB.
const { data: row } = await supabase // Se llama desde AuthContext FUERA del lock de auth, en un useEffect normal.
.from('users') // signal permite cancelar si el servidor tarda demasiado (libera el processLock).
.select('name, avatar_url, platform_role') async syncProfileFromDB(userId: string, signal?: AbortSignal): Promise<Pick<AuthUser, 'name' | 'company' | 'platformRole' | 'orgId'> | null> {
.eq('id', supabaseUser.id) const base = supabase.from('users').select('name, company, platform_role, org_id').eq('id', userId)
.single() const { data } = await (signal ? base.abortSignal(signal) : base).maybeSingle()
if (!data) return null
return {
name: ((data.name as string | null | undefined) || undefined) as unknown as string,
company: (data.company as string | null) ?? undefined,
platformRole: (data.platform_role as AuthUser['platformRole']) ?? 'guest',
orgId: (data.org_id as string | null) ?? undefined,
}
}
// Upsert idempotente al login: crea la fila si no existe, ignora si ya existe.
// Se llama desde AuthContext FUERA del lock de auth, en un useEffect normal.
// signal permite cancelar si el servidor tarda demasiado (libera el processLock).
async upsertUserOnLogin(user: AuthUser, signal?: AbortSignal): Promise<void> {
const builder = supabase.from('users').upsert(
{
id: user.id,
name: user.name,
avatar_url: user.avatarUrl ?? null,
platform_role: user.platformRole,
},
{ onConflict: 'id', ignoreDuplicates: true }
)
const { error } = await (signal ? builder.abortSignal(signal) : builder)
if (error) console.error('Error al crear usuario en DB:', error)
}
// Sincrónico: deriva el usuario de los metadatos de sesión sin consultar la DB.
// El rol se deriva del dominio del email (valor optimista, syncProfileFromDB lo corrige después).
private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): AuthUser {
const email = supabaseUser.email ?? ''
return { return {
id: supabaseUser.id, id: supabaseUser.id,
email, email,
name: row?.name ?? supabaseUser.user_metadata?.full_name as string ?? email, name: (supabaseUser.user_metadata?.full_name as string | undefined) || email,
avatarUrl: row?.avatar_url ?? supabaseUser.user_metadata?.avatar_url as string ?? undefined, avatarUrl: supabaseUser.user_metadata?.avatar_url as string ?? undefined,
platformRole: (row?.platform_role ?? 'guest') as AuthUser['platformRole'], platformRole: email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest',
} }
} }
} }

View File

@@ -3,11 +3,14 @@ export interface AuthUser {
email: string email: string
name: string name: string
avatarUrl?: string avatarUrl?: string
platformRole: 'platform_admin' | 'member' | 'guest' platformRole: 'platform_admin' | 'member' | 'client_editor' | 'client_viewer' | 'guest'
company?: string
orgId?: string
} }
export interface AuthService { export interface AuthService {
signInWithGoogle(): Promise<void> signInWithGoogle(): Promise<void>
signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }>
signOut(): Promise<void> signOut(): Promise<void>
getCurrentUser(): Promise<AuthUser | null> getCurrentUser(): Promise<AuthUser | null>
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void onAuthStateChange(callback: (user: AuthUser | null) => void): () => void

View File

@@ -1,8 +1,10 @@
import { APP_VERSION } from '@/lib/version'
export function AppFooter() { export function AppFooter() {
return ( return (
<footer className="mt-12 py-4 border-t border-slate-100 text-center"> <footer className="mt-12 py-4 border-t border-slate-100 text-center">
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
InQ ROI · Powered by InQuality · v0.1.0 · Hecho desde Paraguay 🇵🇾 InQ ROI · Powered by InQuality · v{APP_VERSION} · Hecho desde Paraguay 🇵🇾
</p> </p>
</footer> </footer>
) )

View File

@@ -1,39 +1,128 @@
import { useState } from 'react'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { LogOut, UserPen, Shield } from 'lucide-react'
import { useAuth } from '@/auth/AuthContext'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import { ProfileSheet } from './ProfileSheet'
export function AppHeader() { function initials(name: string): string {
const parts = name.trim().split(/\s+/)
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
function UserAvatar({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt={name}
className="h-7 w-7 rounded-full object-cover border border-white/40"
/>
)
}
return ( return (
<header <div
style={{ className="h-7 w-7 rounded-full flex items-center justify-center text-[11px] font-medium text-white border border-white/40"
background: 'linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)', style={{ background: '#F59845' }}
height: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingLeft: 20,
paddingRight: 20,
flexShrink: 0,
}}
> >
<Link to="/" style={{ display: 'flex', alignItems: 'center' }}> {initials(name)}
<img </div>
src="/inquality-logo-white.png" )
alt="InQuality" }
style={{ height: 24, objectFit: 'contain', cursor: 'pointer' }}
/> export function AppHeader() {
</Link> const { user, signOut } = useAuth()
<Link const [profileOpen, setProfileOpen] = useState(false)
to="/"
style={{ return (
color: 'white', <>
fontSize: 13, <header
fontWeight: 600, style={{
letterSpacing: '0.04em', background: 'linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)',
opacity: 0.92, height: 44,
textDecoration: 'none', display: 'flex',
}} alignItems: 'center',
> justifyContent: 'space-between',
InQ ROI paddingLeft: 20,
</Link> paddingRight: 20,
</header> flexShrink: 0,
}}
>
<Link to="/" style={{ display: 'flex', alignItems: 'center' }}>
<img
src="/inquality-logo-white.png"
alt="InQuality"
style={{ height: 24, objectFit: 'contain', cursor: 'pointer' }}
/>
</Link>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Link
to="/"
style={{
color: 'white',
fontSize: 13,
fontWeight: 600,
letterSpacing: '0.04em',
opacity: 0.92,
textDecoration: 'none',
}}
>
InQ ROI
</Link>
{user?.platformRole === 'platform_admin' && (
<Link
to="/admin"
title="Panel de administración"
style={{
display: 'flex',
alignItems: 'center',
color: 'white',
opacity: 0.85,
}}
>
<Shield style={{ width: 18, height: 18 }} />
</Link>
)}
{user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="rounded-full focus:outline-none focus:ring-2 focus:ring-white/60" title={user.name}>
<UserAvatar name={user.name} avatarUrl={user.avatarUrl} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
<p className="text-sm font-medium leading-tight">{user.name}</p>
<p className="text-xs text-muted-foreground leading-tight mt-0.5">{user.email}</p>
{user.company && (
<p className="text-xs text-muted-foreground leading-tight mt-0.5">{user.company}</p>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setProfileOpen(true)}>
<UserPen className="h-3.5 w-3.5" />
Editar perfil
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut()} className="text-destructive focus:text-destructive">
<LogOut className="h-3.5 w-3.5" />
Cerrar sesión
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</header>
<ProfileSheet open={profileOpen} onOpenChange={setProfileOpen} />
</>
) )
} }

View File

@@ -0,0 +1,30 @@
import { Component, type ReactNode } from 'react'
interface Props { children: ReactNode; fallback?: ReactNode }
interface State { hasError: boolean; error: Error | null }
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div className="flex flex-col items-center justify-center h-screen gap-2 p-8">
<p className="text-sm font-medium text-destructive">Algo salió mal al cargar el workspace.</p>
<p className="text-xs text-muted-foreground">{this.state.error?.message}</p>
<button
className="text-xs underline text-muted-foreground"
onClick={() => window.location.reload()}
>
Recargar la página
</button>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react'
import { Check } from 'lucide-react'
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { SUPABASE_URL, SUPABASE_ANON_KEY } from '@/lib/supabase'
import { useAuth } from '@/auth/AuthContext'
// Lee el access token directamente de localStorage sin pasar por el cliente Supabase.
// Evita contención con processLock — el save de perfil no necesita refresh de token
// porque es una operación rápida y el token está fresco en sesiones activas.
//
// Clave real confirmada por diagnóstico (Sprint 6 Etapa 3, tests/e2e/auth-localStorage-key.spec.ts):
// 'supabase.auth.token' es el STORAGE_KEY default de @supabase/auth-js's GoTrueClient en
// aislamiento, pero @supabase/supabase-js's createClient() SOBRESCRIBE ese default calculando
// `sb-${projectRef}-auth-token` desde la URL del proyecto (projectRef = hostname.split('.')[0]),
// salvo que el caller pase un storageKey explícito — src/lib/supabase.ts no lo hace. El spec de
// diagnóstico confirmó empíricamente: 'supabase.auth.token' NO existe en localStorage; la única
// clave presente es `sb-${projectRef}-auth-token` con un access_token válido. Con la clave vieja,
// getAccessToken() siempre retornaba null → el persist a Supabase nunca se ejecutaba (línea 68
// silenciaba el error) — el usuario veía el cambio en UI pero no se guardaba.
function getAccessToken(): string | null {
try {
const projectRef = new URL(SUPABASE_URL).hostname.split('.')[0]
const storageKey = `sb-${projectRef}-auth-token`
const raw = localStorage.getItem(storageKey)
if (!raw) return null
const parsed = JSON.parse(raw) as { access_token?: string }
return parsed.access_token ?? null
} catch {
return null
}
}
interface ProfileSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function ProfileSheet({ open, onOpenChange }: ProfileSheetProps) {
const { user, updateUser } = useAuth()
const [name, setName] = useState('')
const [company, setCompany] = useState('')
useEffect(() => {
if (open && user) {
setName(user.name || user.email || '')
setCompany(user.company ?? '')
}
// Intencional: solo inicializar cuando el sheet se abre, no en cada cambio de `user`
// (evita resetear el formulario mientras el usuario está editando).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
function handleSave() {
if (!user || !name.trim()) return
const savedName = name.trim()
const savedCompany = company.trim() || undefined
// Cerramos el Sheet y actualizamos el estado local de inmediato — actualización
// optimista. El fix para el bug de Radix/react-remove-scroll (pointer-events:none
// atascado en el body tras cerrar) vive en src/components/ui/sheet.tsx (useEffect
// que observa el prop `open` y limpia el body defensivamente). El defer de 350ms
// que existía acá era redundante una vez resuelta la causa raíz ahí.
onOpenChange(false)
updateUser({ name: savedName, company: savedCompany })
// Persistir en Supabase en background — fire & forget.
// Usamos raw fetch (no el cliente Supabase) para evitar contención con processLock.
// getSession() interno de supabase.from().update() adquiere processLock y bloquea
// otras queries de la página mientras espera. Raw fetch lee el token de localStorage
// directamente — sin lock, sin overhead de auth.
const token = getAccessToken()
if (!token) return
void fetch(
`${SUPABASE_URL}/rest/v1/users?id=eq.${encodeURIComponent(user.id)}`,
{
method: 'PATCH',
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({ name: savedName, company: savedCompany ?? null }),
signal: AbortSignal.timeout(10000),
}
)
.then((res) => {
if (!res.ok) console.error('[ProfileSheet] Error guardando perfil:', res.status)
})
.catch((err) => {
console.error('[ProfileSheet] Error guardando perfil:', err)
})
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>Editar perfil</SheetTitle>
</SheetHeader>
<div className="space-y-4 mt-6">
<div className="space-y-1.5">
<Label className="text-xs">Nombre</Label>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Tu nombre"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Empresa</Label>
<Input
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="InQuality"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Email</Label>
<Input value={user?.email ?? ''} readOnly className="bg-muted text-muted-foreground cursor-not-allowed" />
</div>
</div>
<SheetFooter className="mt-6 flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button onClick={handleSave} disabled={!name.trim()} className="gap-1.5">
<Check className="h-4 w-4" />
Guardar
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View File

@@ -0,0 +1,30 @@
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useAuth } from '@/auth/AuthContext'
export function RequireRole({
roles,
redirectTo = '/',
children,
}: {
roles: string[]
redirectTo?: '/' | '/library'
children: React.ReactNode
}) {
const { user, isProfileLoaded } = useAuth()
const navigate = useNavigate()
useEffect(() => {
if (!isProfileLoaded) return
if (!user || !roles.includes(user.platformRole)) {
void navigate({ to: redirectTo })
}
// roles y redirectTo son constantes en cada call site — excluidos intencionalmente
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.platformRole, isProfileLoaded, navigate])
if (!isProfileLoaded || !user) return null
if (!roles.includes(user.platformRole)) return null
return <>{children}</>
}

View File

@@ -0,0 +1,76 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { cn } from '@/lib/utils'
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[200px] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-secondary focus:text-secondary-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-medium', className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuPortal,
}

134
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,134 @@
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
// Bug conocido de Radix/react-remove-scroll: si un re-render externo coincide con la
// animación de cierre, el cleanup que restaura `pointer-events` del body no se ejecuta
// — toda la página queda no interactiva hasta refrescar manualmente.
//
// `onOpenChange` de Radix NO es un canal confiable para detectar esto: solo se dispara
// cuando RADIX decide cerrar internamente (Escape, click en overlay, botón X). Cuando el
// padre cierra el Sheet cambiando el prop `open` directamente (ej. ProfileSheet.handleSave
// llama onOpenChange(false), que es setProfileOpen(false) del padre), Radix nunca invoca
// su propio onOpenChange — por eso un wrapper de ese callback no detecta este caso.
// Observar el prop `open` en sí cubre ambos canales de cierre.
function Sheet({ open, ...props }: React.ComponentPropsWithoutRef<typeof SheetPrimitive.Root>) {
React.useEffect(() => {
if (open) return
// 400ms > duración de la animación de salida (300ms, ver sheetVariants).
const timeoutId = window.setTimeout(() => {
const stillHasOpenDialog = document.querySelector('[role="dialog"][data-state="open"]')
if (!stillHasOpenDialog && document.body.style.pointerEvents === 'none') {
document.body.style.pointerEvents = ''
}
}, 400)
return () => window.clearTimeout(timeoutId)
}, [open])
return <SheetPrimitive.Root open={open} {...props} />
}
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
left:
'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
},
},
defaultVariants: { side: 'right' },
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none">
<X className="h-4 w-4" />
<span className="sr-only">Cerrar</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-left', className)} {...props} />
)
SheetHeader.displayName = 'SheetHeader'
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2', className)} {...props} />
)
SheetFooter.displayName = 'SheetFooter'
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-base font-semibold text-foreground', className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,13 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-slate-200', className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -54,6 +54,7 @@ export const ProcessSchema = z.object({
automationInvestment: z.number().min(0).default(0), automationInvestment: z.number().min(0).default(0),
createdAt: z.number(), createdAt: z.number(),
updatedAt: z.number(), updatedAt: z.number(),
areaId: z.string().uuid().nullable(),
}) })
export type ResourceInput = z.infer<typeof ResourceSchema> export type ResourceInput = z.infer<typeof ResourceSchema>

View File

@@ -18,6 +18,12 @@ export interface Process {
updatedAt: number updatedAt: number
groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar") groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar")
tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda
ownerId: string // 🆕 Sprint 4 — owner del proceso (auth.uid() al crear)
updatedBy: string // 🆕 Sprint 4 — UUID del usuario que hizo el último update
orgId: string | null // 🆕 Sprint 7 — organización dueña del proceso (null = proceso interno InQ)
orgName?: string | null // 🆕 Sprint 7 — nombre de la org (poblado por getById via JOIN, no se persiste)
areaId: string | null // 🆕 Sprint 8 — área de la org (null = sin área)
areaName?: string | null // 🆕 Sprint 8 — poblado via JOIN en getById
} }
export type ActivityType = 'task' | 'subprocess' export type ActivityType = 'task' | 'subprocess'
@@ -63,7 +69,6 @@ export type ResourceType = 'role' | 'person' | 'system' | 'equipment' | 'supply'
export interface Resource { export interface Resource {
id: UUID id: UUID
processId: UUID
name: string name: string
type: ResourceType type: ResourceType
costPerHour: number costPerHour: number
@@ -86,6 +91,14 @@ export interface ProcessGroup {
updatedAt: number // timestamp Unix ms updatedAt: number // timestamp Unix ms
} }
export interface Area {
id: string
orgId: string
name: string
description: string | null
createdAt: number // timestamp Unix ms
}
export interface AppSettings { export interface AppSettings {
id: 'singleton' // siempre un único registro por instalación id: 'singleton' // siempre un único registro por instalación
groupLabel: string // etiqueta configurable para el nivel de agrupación. Default: 'Cliente' groupLabel: string // etiqueta configurable para el nivel de agrupación. Default: 'Cliente'

View File

@@ -0,0 +1,51 @@
import { Outlet, Link, useRouterState } from '@tanstack/react-router'
import { AppHeader } from '@/components/AppHeader'
import { RequireRole } from '@/components/RequireRole'
import { cn } from '@/lib/utils'
export function AdminLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<RequireRole roles={['platform_admin']}>
<div className="min-h-screen flex flex-col bg-slate-50">
<AppHeader />
<div className="flex-1 max-w-6xl mx-auto w-full px-6 py-8">
<nav className="flex gap-1 border-b border-slate-200 mb-6">
<AdminNavTab to="/admin/organizations" active={pathname.startsWith('/admin/organizations')}>
Organizaciones
</AdminNavTab>
<AdminNavTab to="/admin/users" active={pathname.startsWith('/admin/users')}>
Usuarios
</AdminNavTab>
</nav>
<Outlet />
</div>
</div>
</RequireRole>
)
}
function AdminNavTab({
to,
active,
children,
}: {
to: string
active: boolean
children: React.ReactNode
}) {
return (
<Link
to={to}
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
active
? 'border-[#F59845] text-[#F59845]'
: 'border-transparent text-slate-600 hover:text-slate-900'
)}
>
{children}
</Link>
)
}

View File

@@ -0,0 +1,523 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate, Link } from '@tanstack/react-router'
import { ArrowLeft, FileText, Users, FolderX, UserX, GitBranch, FolderOpen, Plus, Pencil, Trash2 } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { RoleBadge } from './RoleBadge'
import { supabaseAreaRepo } from '@/persistence/supabase/area-repo'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface OrgProcess {
id: string
name: string
client: string | null
area_id: string | null
updated_at: string
}
interface AreaWithCount {
id: string
name: string
description: string | null
processCount: number
}
interface OrgUser {
id: string
name: string | null
company: string | null
platform_role: string
avatar_url?: string | null
}
interface UnassignedProcess {
id: string
name: string
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('es-PY', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
export function OrganizationDetailPage() {
const { orgId } = useParams({ from: '/admin/organizations/$orgId' })
const navigate = useNavigate()
const [orgName, setOrgName] = useState<string>('')
const [processes, setProcesses] = useState<OrgProcess[]>([])
const [users, setUsers] = useState<OrgUser[]>([])
const [loading, setLoading] = useState(true)
// Asignar proceso
const [assignOpen, setAssignOpen] = useState(false)
const [unassigned, setUnassigned] = useState<UnassignedProcess[]>([])
const [loadingUnassigned, setLoadingUnassigned] = useState(false)
const [selectedProcessId, setSelectedProcessId] = useState<string>('')
const [assigning, setAssigning] = useState(false)
const [assignError, setAssignError] = useState<string | null>(null)
// Áreas de la org
const [areas, setAreas] = useState<AreaWithCount[]>([])
const [areasLoading, setAreasLoading] = useState(false)
// Sheet crear/editar área
const [areaSheetOpen, setAreaSheetOpen] = useState(false)
const [editingAreaId, setEditingAreaId] = useState<string | null>(null)
const [areaNameInput, setAreaNameInput] = useState('')
const [savingArea, setSavingArea] = useState(false)
const [areaFormError, setAreaFormError] = useState<string | null>(null)
// Confirmación de delete
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const [deletingAreaId, setDeletingAreaId] = useState<string | null>(null)
const fetchDetail = async () => {
const [orgRes, procRes, userRes] = await Promise.all([
supabase.from('organizations').select('name').eq('id', orgId).single(),
supabase
.from('processes')
.select('id, name, client, area_id, updated_at')
.eq('org_id', orgId)
.order('updated_at', { ascending: false }),
supabase.from('users').select('id, name, company, platform_role, avatar_url').eq('org_id', orgId).order('name'),
])
if (orgRes.data) setOrgName((orgRes.data as { name: string }).name)
const loadedProcesses = (procRes.data as OrgProcess[] | null) ?? []
setProcesses(loadedProcesses)
setUsers((userRes.data as OrgUser[] | null) ?? [])
setLoading(false)
// Cargar áreas y calcular conteo de procesos
setAreasLoading(true)
try {
const rawAreas = await supabaseAreaRepo.getByOrg(orgId)
const withCount: AreaWithCount[] = rawAreas.map((a) => ({
id: a.id,
name: a.name,
description: a.description,
processCount: loadedProcesses.filter((p) => p.area_id === a.id).length,
}))
setAreas(withCount)
} catch {
setAreas([])
} finally {
setAreasLoading(false)
}
}
useEffect(() => { void fetchDetail() }, [orgId])
const openAssign = async () => {
setSelectedProcessId('')
setAssignError(null)
setAssignOpen(true)
setLoadingUnassigned(true)
const { data } = await supabase
.from('processes')
.select('id, name')
.is('org_id', null)
.order('name')
setUnassigned((data as UnassignedProcess[] | null) ?? [])
setLoadingUnassigned(false)
}
const handleAssign = async () => {
if (!selectedProcessId) {
setAssignError('Seleccioná un proceso')
return
}
setAssigning(true)
const { error } = await supabase
.from('processes')
.update({ org_id: orgId })
.eq('id', selectedProcessId)
if (error) {
setAssignError(error.message)
setAssigning(false)
} else {
setAssignOpen(false)
setAssigning(false)
void fetchDetail()
}
}
const openCreateArea = () => {
setEditingAreaId(null)
setAreaNameInput('')
setAreaFormError(null)
setAreaSheetOpen(true)
}
const openEditArea = (area: AreaWithCount) => {
setEditingAreaId(area.id)
setAreaNameInput(area.name)
setAreaFormError(null)
setAreaSheetOpen(true)
}
const handleSaveArea = async () => {
const name = areaNameInput.trim()
if (!name) { setAreaFormError('El nombre del área es requerido'); return }
setSavingArea(true)
setAreaFormError(null)
try {
if (editingAreaId) {
await supabaseAreaRepo.update(editingAreaId, { name })
} else {
await supabaseAreaRepo.create({ orgId, name })
}
setAreaSheetOpen(false)
void fetchDetail()
} catch (e) {
setAreaFormError(e instanceof Error ? e.message : 'Error al guardar el área')
} finally {
setSavingArea(false)
}
}
const handleDeleteArea = async (areaId: string) => {
setDeletingAreaId(areaId)
try {
await supabaseAreaRepo.delete(areaId)
setDeleteConfirmId(null)
void fetchDetail()
} catch (e) {
console.error('Error eliminando área:', e)
} finally {
setDeletingAreaId(null)
}
}
if (loading) {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-48 w-full" />
</div>
)
}
return (
<>
<div className="flex items-center gap-3 mb-6">
<Button
variant="ghost"
size="sm"
onClick={() => navigate({ to: '/admin/organizations' })}
className="text-slate-500 hover:text-slate-900"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Volver
</Button>
<h2 className="text-lg font-semibold text-slate-900">{orgName}</h2>
</div>
<Tabs defaultValue="processes">
<TabsList className="mb-4">
<TabsTrigger value="processes">
<FileText className="h-4 w-4 mr-1.5" />
Procesos ({processes.length})
</TabsTrigger>
<TabsTrigger value="users">
<Users className="h-4 w-4 mr-1.5" />
Usuarios ({users.length})
</TabsTrigger>
<TabsTrigger value="areas">
<FolderOpen className="h-4 w-4 mr-1.5" />
Áreas ({areas.length})
</TabsTrigger>
</TabsList>
{/* ─── Tab: Procesos ─────────────────────────────────────────────── */}
<TabsContent value="processes">
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-slate-500">
{processes.length === 0
? 'No hay procesos asignados a esta organización.'
: `${processes.length} proceso${processes.length !== 1 ? 's' : ''} asignado${processes.length !== 1 ? 's' : ''}.`}
</p>
<Button size="sm" variant="outline" onClick={openAssign}>
<GitBranch className="h-4 w-4 mr-1.5" />
Asignar proceso existente
</Button>
</div>
{processes.length === 0 ? (
<EmptyState
icon={<FolderX className="h-10 w-10 text-slate-300" />}
message="Esta organización no tiene procesos asignados. Podés asignar uno existente o importar uno nuevo."
/>
) : (
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Proceso</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Última actualización</th>
<th className="py-2.5 px-4" />
</tr>
</thead>
<tbody>
{processes.map((p) => (
<tr key={p.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
<td className="py-3 px-4 font-medium text-slate-900">{p.name}</td>
<td className="py-3 px-4 text-slate-500">{formatDate(p.updated_at)}</td>
<td className="py-3 px-4 text-right">
<Link
to="/workspace/$processId"
params={{ processId: p.id }}
className="text-xs font-semibold text-[#F59845] hover:text-[#e0872e]"
>
Abrir workspace
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
{/* ─── Tab: Usuarios ─────────────────────────────────────────────── */}
<TabsContent value="users">
{users.length === 0 ? (
<EmptyState
icon={<UserX className="h-10 w-10 text-slate-300" />}
message="No hay usuarios en esta organización. Invitá usuarios desde la sección Usuarios."
/>
) : (
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Nombre</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Rol</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Empresa</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-slate-100 last:border-0">
<td className="py-3 px-4">
<span className="font-medium text-slate-900">{u.name || '—'}</span>
{!u.name && !u.avatar_url && (
<span className="ml-2 text-[10px] font-medium px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
Invitación pendiente
</span>
)}
</td>
<td className="py-3 px-4">
<RoleBadge role={u.platform_role} />
</td>
<td className="py-3 px-4 text-slate-500">{u.company ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
{/* ─── Tab: Áreas ──────────────────────────────────────────────── */}
<TabsContent value="areas">
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-slate-500">
{areasLoading
? 'Cargando áreas…'
: areas.length === 0
? 'No hay áreas en esta organización.'
: `${areas.length} área${areas.length !== 1 ? 's' : ''}.`}
</p>
<Button size="sm" variant="outline" onClick={openCreateArea}>
<Plus className="h-4 w-4 mr-1.5" />
Nueva área
</Button>
</div>
{areasLoading ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : areas.length === 0 ? (
<EmptyState
icon={<FolderOpen className="h-10 w-10 text-slate-300" />}
message="Las áreas agrupan procesos dentro de una organización (ej: Operaciones, RR.HH., Finanzas)."
/>
) : (
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Área</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Procesos</th>
<th className="py-2.5 px-4" />
</tr>
</thead>
<tbody>
{areas.map((area) => (
<tr key={area.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
<td className="py-3 px-4 font-medium text-slate-900">{area.name}</td>
<td className="py-3 px-4 text-slate-500">
{area.processCount} {area.processCount === 1 ? 'proceso' : 'procesos'}
</td>
<td className="py-3 px-4">
{deleteConfirmId === area.id ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-slate-500">
{area.processCount > 0
? `${area.processCount} proceso${area.processCount !== 1 ? 's' : ''} quedarán sin área.`
: '¿Eliminar esta área?'}
</span>
<Button
size="sm"
variant="destructive"
className="h-7 text-xs px-2"
disabled={deletingAreaId === area.id}
onClick={() => handleDeleteArea(area.id)}
>
{deletingAreaId === area.id ? 'Eliminando…' : 'Confirmar'}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs px-2"
onClick={() => setDeleteConfirmId(null)}
>
Cancelar
</Button>
</div>
) : (
<div className="flex items-center justify-end gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-slate-400 hover:text-slate-700"
onClick={() => openEditArea(area)}
title="Renombrar área"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-slate-400 hover:text-red-600"
onClick={() => setDeleteConfirmId(area.id)}
title="Eliminar área"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
</Tabs>
{/* ─── Sheet: Asignar proceso ─────────────────────────────────────── */}
<Sheet open={assignOpen} onOpenChange={setAssignOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Asignar proceso a {orgName}</SheetTitle>
</SheetHeader>
<div className="mt-5 flex flex-col gap-4">
{loadingUnassigned ? (
<Skeleton className="h-9 w-full" />
) : unassigned.length === 0 ? (
<p className="text-sm text-slate-500">No hay procesos sin organización asignada.</p>
) : (
<Select value={selectedProcessId} onValueChange={setSelectedProcessId}>
<SelectTrigger>
<SelectValue placeholder="Seleccioná un proceso" />
</SelectTrigger>
<SelectContent>
{unassigned.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
{assignError && <p className="text-xs text-red-600">{assignError}</p>}
<SheetFooter>
<Button
onClick={handleAssign}
disabled={assigning || loadingUnassigned || unassigned.length === 0}
>
{assigning ? 'Asignando…' : 'Asignar proceso'}
</Button>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
{/* ─── Sheet: Crear/editar área ────────────────────────────────── */}
<Sheet open={areaSheetOpen} onOpenChange={setAreaSheetOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>
{editingAreaId ? 'Renombrar área' : `Nueva área en ${orgName}`}
</SheetTitle>
</SheetHeader>
<div className="mt-5 flex flex-col gap-4">
<div className="space-y-1.5">
<Label htmlFor="area-name" className="text-sm font-medium">
Nombre del área
</Label>
<Input
id="area-name"
value={areaNameInput}
onChange={(e) => { setAreaNameInput(e.target.value); setAreaFormError(null) }}
placeholder="ej. Operaciones, RR.HH., Finanzas"
className="h-9"
onKeyDown={(e) => { if (e.key === 'Enter') void handleSaveArea() }}
autoFocus
/>
{areaFormError && (
<p className="text-xs text-red-600">{areaFormError}</p>
)}
</div>
<SheetFooter>
<Button onClick={handleSaveArea} disabled={savingArea || !areaNameInput.trim()}>
{savingArea ? 'Guardando…' : editingAreaId ? 'Guardar cambios' : 'Crear área'}
</Button>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
</>
)
}
function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
{icon}
<p className="text-sm text-slate-500 max-w-sm">{message}</p>
</div>
)
}

View File

@@ -0,0 +1,213 @@
import { useEffect, useState } from 'react'
import { Link } from '@tanstack/react-router'
import { Building2, Plus } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet'
interface OrgRow {
id: string
name: string
is_provider: boolean
created_at: string
processes: [{ count: number }]
users: [{ count: number }]
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('es-PY', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
export function OrganizationsPage() {
const [orgs, setOrgs] = useState<OrgRow[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [orgName, setOrgName] = useState('')
const [createError, setCreateError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const fetchOrgs = async () => {
const { data } = await supabase
.from('organizations')
.select('id, name, is_provider, created_at, processes:processes(count), users:users(count)')
.order('name')
const sorted = ((data as OrgRow[] | null) ?? []).sort((a, b) => {
if (a.is_provider && !b.is_provider) return -1
if (!a.is_provider && b.is_provider) return 1
return a.name.localeCompare(b.name)
})
setOrgs(sorted)
setLoading(false)
}
useEffect(() => { void fetchOrgs() }, [])
const openCreate = () => {
setOrgName('')
setCreateError(null)
setCreateOpen(true)
}
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
const trimmed = orgName.trim()
if (!trimmed) {
setCreateError('El nombre es requerido')
return
}
setCreating(true)
const { error } = await supabase
.from('organizations')
.insert({ name: trimmed, is_provider: false })
if (error) {
setCreateError(
error.message.toLowerCase().includes('duplicate') || error.message.toLowerCase().includes('unique')
? 'Ya existe una organización con ese nombre'
: error.message
)
setCreating(false)
} else {
setCreateOpen(false)
setCreating(false)
void fetchOrgs()
}
}
return (
<>
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold text-slate-900">Organizaciones</h2>
<Button size="sm" onClick={openCreate}>
<Plus className="h-4 w-4 mr-1.5" />
Nueva organización
</Button>
</div>
{loading ? (
<TableSkeleton rows={5} />
) : orgs.length === 0 ? (
<EmptyState
icon={<Building2 className="h-10 w-10 text-slate-300" />}
message="No hay organizaciones clientes aún. Creá la primera con el botón de arriba."
/>
) : (
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Organización</th>
<th className="text-right py-2.5 px-4 font-medium text-slate-600">Procesos</th>
<th className="text-right py-2.5 px-4 font-medium text-slate-600">Usuarios</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Creada</th>
<th className="py-2.5 px-4" />
</tr>
</thead>
<tbody>
{orgs.map((org) => (
<tr key={org.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50 transition-colors">
<td className="py-3 px-4 font-medium text-slate-900">
<div className="flex items-center gap-2">
{org.name}
{org.is_provider && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full border"
style={{ color: '#F59845', background: '#fff8f0', borderColor: '#fcd9aa' }}>
InQuality
</span>
)}
</div>
</td>
<td className="py-3 px-4 text-right text-slate-600 tabular-nums">
{org.processes[0]?.count ?? 0}
</td>
<td className="py-3 px-4 text-right text-slate-600 tabular-nums">
{org.users[0]?.count ?? 0}
</td>
<td className="py-3 px-4 text-slate-500">{formatDate(org.created_at)}</td>
<td className="py-3 px-4 text-right">
<Link
to="/admin/organizations/$orgId"
params={{ orgId: org.id }}
className="text-xs font-semibold text-[#F59845] hover:text-[#e0872e] transition-colors"
>
Ver detalle
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Nueva organización</SheetTitle>
</SheetHeader>
<form onSubmit={handleCreate} className="mt-5 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="create-org-name">Nombre</Label>
<Input
id="create-org-name"
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
placeholder="ej. Banco Solar SA"
disabled={creating}
autoFocus
/>
{createError && <p className="text-xs text-red-600">{createError}</p>}
</div>
<SheetFooter className="mt-2">
<Button type="submit" disabled={creating}>
{creating ? 'Creando…' : 'Crear organización'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
</>
)
}
function TableSkeleton({ rows }: { rows: number }) {
return (
<div className="rounded-lg border border-slate-200 overflow-hidden">
<div className="bg-slate-50 border-b border-slate-200 h-10" />
<div className="flex flex-col divide-y divide-slate-100">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4 px-4 py-3 bg-white">
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
)
}
function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
{icon}
<p className="text-sm text-slate-500 max-w-xs">{message}</p>
</div>
)
}

View File

@@ -0,0 +1,38 @@
// Badges de rol con colores por spec: platform_admin naranja, member slate,
// client_editor azul, client_viewer verde, guest gris/suspendido
const ROLE_STYLES: Record<string, { label: string; className: string }> = {
platform_admin: {
label: 'Platform Admin',
className: 'bg-orange-100 text-orange-700 border-orange-200',
},
member: {
label: 'Member',
className: 'bg-slate-100 text-slate-700 border-slate-200',
},
client_editor: {
label: 'Client Editor',
className: 'bg-blue-100 text-blue-700 border-blue-200',
},
client_viewer: {
label: 'Client Viewer',
className: 'bg-emerald-100 text-emerald-700 border-emerald-200',
},
guest: {
label: 'Suspendido',
className: 'bg-gray-100 text-gray-500 border-gray-200',
},
}
export function RoleBadge({ role }: { role: string }) {
const style = ROLE_STYLES[role] ?? {
label: role,
className: 'bg-gray-100 text-gray-500 border-gray-200',
}
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-medium border ${style.className}`}
>
{style.label}
</span>
)
}

View File

@@ -0,0 +1,632 @@
/**
* Limitaciones conocidas:
* - "Reactivar" restaura siempre a 'client_editor' — no se guarda el rol previo a la suspensión.
*/
import { useEffect, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { UserX, UserPlus, MoreVertical, Trash2 } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/auth/AuthContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useToast } from '@/components/ui/use-toast'
import { RoleBadge } from './RoleBadge'
interface AdminUser {
id: string
name: string | null
company: string | null
platform_role: string
org_id: string | null
avatar_url: string | null
organization: { name: string; is_provider: boolean } | null
email?: string | null
}
interface OrgOption {
id: string
name: string
}
// ─── ConfirmDialog usando @radix-ui/react-dialog (sin @radix-ui/react-alert-dialog) ──
interface ConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
confirmLabel?: string
onConfirm: () => void
loading?: boolean
destructive?: boolean
}
function ConfirmDialog({
open, onOpenChange, title, description,
confirmLabel = 'Confirmar',
onConfirm, loading = false, destructive = false,
}: ConfirmDialogProps) {
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-xl shadow-xl p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
onInteractOutside={(e) => e.preventDefault()}
>
<DialogPrimitive.Title className="text-base font-semibold text-slate-900">
{title}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="mt-2 text-sm text-slate-600">
{description}
</DialogPrimitive.Description>
<div className="flex justify-end gap-2 mt-6">
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancelar
</Button>
<Button
size="sm"
variant={destructive ? 'destructive' : 'default'}
onClick={onConfirm}
disabled={loading}
>
{loading ? 'Procesando…' : confirmLabel}
</Button>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
// ─── Página principal ─────────────────────────────────────────────────────────
const INVITE_ROLES = ['client_editor', 'client_viewer', 'member'] as const
type InviteRole = typeof INVITE_ROLES[number]
const CHANGE_ROLES = ['platform_admin', 'member', 'client_editor', 'client_viewer', 'guest'] as const
export function UsersPage() {
const { user: adminUser } = useAuth()
const { toast } = useToast()
const [users, setUsers] = useState<AdminUser[]>([])
const [orgs, setOrgs] = useState<OrgOption[]>([])
const [loadingUsers, setLoadingUsers] = useState(true)
const [filterOrgId, setFilterOrgId] = useState<string>('all')
// Invite sheet
const [inviteOpen, setInviteOpen] = useState(false)
const [inviteName, setInviteName] = useState('')
const [inviteEmail, setInviteEmail] = useState('')
const [inviteRole, setInviteRole] = useState<InviteRole>('client_editor')
const [inviteOrgName, setInviteOrgName] = useState('')
const [inviteError, setInviteError] = useState<string | null>(null)
const [inviteSuccess, setInviteSuccess] = useState<string | null>(null)
const [inviting, setInviting] = useState(false)
// Change role sheet
const [changeRoleUser, setChangeRoleUser] = useState<AdminUser | null>(null)
const [changeRoleOpen, setChangeRoleOpen] = useState(false)
const [newRole, setNewRole] = useState<string>('')
const [changingRole, setChangingRole] = useState(false)
const [changeRoleError, setChangeRoleError] = useState<string | null>(null)
// Confirmations
const [suspendTarget, setSuspendTarget] = useState<AdminUser | null>(null)
const [suspending, setSuspending] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
const [deleting, setDeleting] = useState(false)
const [reactivateTarget, setReactivateTarget] = useState<AdminUser | null>(null)
const [reactivating, setReactivating] = useState(false)
const fetchUsers = async () => {
const query = supabase
.from('users')
.select('id, name, company, platform_role, org_id, avatar_url, organization:organizations(name, is_provider)')
.order('name')
const finalQuery = filterOrgId !== 'all'
? query.eq('org_id', filterOrgId)
: query
const [{ data }, { data: emailData }] = await Promise.all([
finalQuery,
supabase.functions.invoke('list-users'),
])
const emailMap = new Map<string, string | null>()
if (emailData) {
for (const u of (emailData as Array<{ id: string; email: string | null }>) ?? []) {
emailMap.set(u.id, u.email ?? null)
}
}
const usersWithEmail = ((data as AdminUser[] | null) ?? []).map((u) => ({
...u,
email: emailMap.get(u.id) ?? null,
}))
setUsers(usersWithEmail)
setLoadingUsers(false)
}
const fetchOrgs = async () => {
const { data } = await supabase
.from('organizations')
.select('id, name')
.order('name')
setOrgs((data as OrgOption[] | null) ?? [])
}
useEffect(() => {
void fetchOrgs()
}, [])
useEffect(() => {
setLoadingUsers(true)
void fetchUsers()
}, [filterOrgId]) // eslint-disable-line react-hooks/exhaustive-deps
// ── Invite user ──────────────────────────────────────────────────────────
const openInvite = () => {
setInviteName('')
setInviteEmail('')
setInviteRole('client_editor')
setInviteOrgName('')
setInviteError(null)
setInviteSuccess(null)
setInviteOpen(true)
}
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault()
setInviteError(null)
setInviteSuccess(null)
if (!inviteName.trim()) { setInviteError('El nombre es requerido'); return }
if (!inviteEmail.trim() || !inviteEmail.includes('@')) { setInviteError('Email inválido'); return }
if ((inviteRole === 'client_editor' || inviteRole === 'client_viewer') && !inviteOrgName.trim()) {
setInviteError('La organización es requerida para este rol')
return
}
setInviting(true)
const { error } = await supabase.functions.invoke('invite-user', {
body: {
email: inviteEmail.trim(),
name: inviteName.trim(),
platform_role: inviteRole,
org_name: inviteRole === 'member' ? 'InQuality' : inviteOrgName.trim(),
},
})
if (error) {
setInviteError(typeof error === 'object' && 'message' in error ? String(error.message) : String(error))
setInviting(false)
} else {
setInviteSuccess(`Invitación enviada a ${inviteEmail.trim()}`)
setInviting(false)
void fetchUsers()
}
}
// ── Change role ──────────────────────────────────────────────────────────
const openChangeRole = (u: AdminUser) => {
setChangeRoleUser(u)
setNewRole(u.platform_role)
setChangeRoleError(null)
setChangeRoleOpen(true)
}
const handleChangeRole = async () => {
if (!changeRoleUser || !newRole) return
setChangingRole(true)
const { error } = await supabase
.from('users')
.update({ platform_role: newRole })
.eq('id', changeRoleUser.id)
if (error) {
setChangeRoleError(error.message)
setChangingRole(false)
} else {
setChangeRoleOpen(false)
setChangingRole(false)
void fetchUsers()
}
}
// ── Suspend (revoke-user vía Edge Function) ───────────────────────────────
const handleSuspend = async () => {
if (!suspendTarget) return
setSuspending(true)
const { error } = await supabase.functions.invoke('revoke-user', {
body: { userId: suspendTarget.id },
})
if (error) {
console.error('Error al suspender usuario:', error)
}
setSuspendTarget(null)
setSuspending(false)
void fetchUsers()
}
// ── Hard delete vía Edge Function delete-user ─────────────────────────────
const handleDelete = async () => {
if (!deleteTarget) return
setDeleting(true)
const { error } = await supabase.functions.invoke('delete-user', {
body: { userId: deleteTarget.id },
})
if (error) {
toast({
title: 'Error al eliminar usuario',
description: typeof error === 'object' && 'message' in error
? String(error.message)
: 'Ocurrió un error inesperado.',
variant: 'destructive',
})
} else {
toast({ title: 'Usuario eliminado permanentemente' })
}
setDeleteTarget(null)
setDeleting(false)
void fetchUsers()
}
// ── Reactivate (restore to client_editor — rol previo no se guarda) ───────
const handleReactivate = async () => {
if (!reactivateTarget) return
setReactivating(true)
await supabase
.from('users')
.update({ platform_role: 'client_editor' })
.eq('id', reactivateTarget.id)
setReactivateTarget(null)
setReactivating(false)
void fetchUsers()
}
// ─────────────────────────────────────────────────────────────────────────
const displayedUsers = users
return (
<>
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-slate-900">Usuarios</h2>
<Select value={filterOrgId} onValueChange={setFilterOrgId}>
<SelectTrigger className="w-48 h-8 text-xs">
<SelectValue placeholder="Filtrar por org" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas las organizaciones</SelectItem>
{orgs.map((o) => (
<SelectItem key={o.id} value={o.id}>{o.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button size="sm" onClick={openInvite}>
<UserPlus className="h-4 w-4 mr-1.5" />
Invitar usuario
</Button>
</div>
{loadingUsers ? (
<TableSkeleton rows={6} />
) : displayedUsers.length === 0 ? (
<EmptyState
icon={<UserX className="h-10 w-10 text-slate-300" />}
message={
filterOrgId !== 'all'
? 'No hay usuarios en esta organización.'
: 'No hay usuarios registrados aún.'
}
/>
) : (
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Nombre</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Email</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Rol</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Organización</th>
<th className="py-2.5 px-4" />
</tr>
</thead>
<tbody>
{displayedUsers.map((u) => {
const isSelf = adminUser?.id === u.id
const isPending = !u.name && !u.avatar_url
return (
<tr key={u.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<span className={`font-medium ${u.platform_role === 'guest' ? 'text-slate-400' : 'text-slate-900'}`}>
{u.name || <span className="italic text-slate-400">Sin nombre</span>}
</span>
{isPending && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
Invitación pendiente
</span>
)}
{isSelf && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-slate-100 text-slate-500">
</span>
)}
</div>
</td>
<td className="py-3 px-4 text-slate-500 text-xs font-mono">
{u.email ?? '—'}
</td>
<td className="py-3 px-4">
<RoleBadge role={u.platform_role} />
</td>
<td className="py-3 px-4 text-slate-500">
{u.organization?.name ?? '—'}
</td>
<td className="py-3 px-4 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={isSelf}
title={isSelf ? 'No puedes modificar tu propio usuario' : undefined}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openChangeRole(u)}>
Cambiar rol
</DropdownMenuItem>
<DropdownMenuSeparator />
{u.platform_role === 'guest' ? (
<DropdownMenuItem onClick={() => setReactivateTarget(u)}>
Reactivar usuario
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="text-amber-600 focus:text-amber-600"
onClick={() => setSuspendTarget(u)}
>
Suspender
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteTarget(u)}
>
<Trash2 className="h-3.5 w-3.5 mr-2" />
Eliminar permanentemente
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* ─── Sheet: Invitar usuario ──────────────────────────────────────── */}
<Sheet open={inviteOpen} onOpenChange={setInviteOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Invitar usuario</SheetTitle>
</SheetHeader>
<form onSubmit={handleInvite} className="mt-5 flex flex-col gap-4">
<Field label="Nombre completo" id="invite-name">
<Input
id="invite-name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
placeholder="Ej. Ana Martínez"
disabled={inviting}
autoFocus
/>
</Field>
<Field label="Email" id="invite-email">
<Input
id="invite-email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="ana@empresa.com"
disabled={inviting}
/>
</Field>
<Field label="Rol" id="invite-role">
<Select
value={inviteRole}
onValueChange={(v) => setInviteRole(v as InviteRole)}
disabled={inviting}
>
<SelectTrigger id="invite-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="client_editor">Client Editor</SelectItem>
<SelectItem value="client_viewer">Client Viewer</SelectItem>
<SelectItem value="member">Member (InQuality)</SelectItem>
</SelectContent>
</Select>
</Field>
{inviteRole === 'member' ? (
<Field label="Organización" id="invite-org-member">
<Input id="invite-org-member" value="InQuality" disabled />
</Field>
) : (
<Field label="Organización" id="invite-org">
<Input
id="invite-org"
list="orgs-datalist"
value={inviteOrgName}
onChange={(e) => setInviteOrgName(e.target.value)}
placeholder="Nombre de la organización"
disabled={inviting}
/>
<datalist id="orgs-datalist">
{orgs.map((o) => (
<option key={o.id} value={o.name} />
))}
</datalist>
</Field>
)}
{inviteError && <p className="text-xs text-red-600">{inviteError}</p>}
{inviteSuccess && <p className="text-xs text-emerald-600 font-medium">{inviteSuccess}</p>}
<SheetFooter className="mt-2">
<Button type="submit" disabled={inviting}>
{inviting ? 'Enviando invitación…' : 'Enviar invitación'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
{/* ─── Sheet: Cambiar rol ──────────────────────────────────────────── */}
<Sheet open={changeRoleOpen} onOpenChange={setChangeRoleOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Cambiar rol {changeRoleUser?.name}</SheetTitle>
</SheetHeader>
<div className="mt-5 flex flex-col gap-4">
<Field label="Nuevo rol" id="change-role-select">
<Select value={newRole} onValueChange={setNewRole} disabled={changingRole}>
<SelectTrigger id="change-role-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CHANGE_ROLES.map((r) => (
<SelectItem key={r} value={r}>{r}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{changeRoleError && <p className="text-xs text-red-600">{changeRoleError}</p>}
<SheetFooter className="mt-2">
<Button onClick={handleChangeRole} disabled={changingRole}>
{changingRole ? 'Guardando…' : 'Guardar rol'}
</Button>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
{/* ─── ConfirmDialog: Suspender ────────────────────────────────────── */}
<ConfirmDialog
open={!!suspendTarget}
onOpenChange={(open) => !open && setSuspendTarget(null)}
title={`¿Suspender a ${suspendTarget?.name ?? 'este usuario'}?`}
description="No podrá acceder a la plataforma mientras esté suspendido. Podés reactivarlo después."
confirmLabel="Suspender"
onConfirm={handleSuspend}
loading={suspending}
destructive
/>
{/* ─── ConfirmDialog: Eliminar permanentemente ────────────────────── */}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
title={`¿Eliminar permanentemente a ${deleteTarget?.name ?? 'este usuario'}?`}
description="Esta acción no puede deshacerse. El usuario perderá acceso inmediatamente y sus datos de autenticación serán eliminados de forma permanente."
confirmLabel="Eliminar permanentemente"
onConfirm={handleDelete}
loading={deleting}
destructive
/>
{/* ─── ConfirmDialog: Reactivar ────────────────────────────────────── */}
<ConfirmDialog
open={!!reactivateTarget}
onOpenChange={(open) => !open && setReactivateTarget(null)}
title={`¿Reactivar a ${reactivateTarget?.name ?? 'este usuario'}?`}
description="Se le asignará el rol Client Editor. (Nota: el rol original no se conservó al momento de la suspensión.)"
confirmLabel="Reactivar"
onConfirm={handleReactivate}
loading={reactivating}
/>
</>
)
}
function Field({ label, id, children }: { label: string; id: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
)
}
function TableSkeleton({ rows }: { rows: number }) {
return (
<div className="rounded-lg border border-slate-200 overflow-hidden">
<div className="bg-slate-50 border-b border-slate-200 h-10" />
<div className="flex flex-col divide-y divide-slate-100">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4 px-4 py-3 bg-white items-center">
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-5 w-20 rounded" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-6 w-6 rounded" />
</div>
))}
</div>
</div>
)
}
function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
{icon}
<p className="text-sm text-slate-500 max-w-xs">{message}</p>
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { useState } from 'react'
import { useNavigate, useSearch, Link } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { supabase } from '@/lib/supabase'
import { Button } from '@/components/ui/button'
type Status = 'idle' | 'loading' | 'error'
export function ConfirmInvitePage() {
const navigate = useNavigate()
const search = useSearch({ from: '/auth/confirm' })
const { token_hash: tokenHash, type: inviteType } = search as {
token_hash?: string
type?: string
}
const paramsValid = Boolean(tokenHash) && inviteType === 'invite'
const [status, setStatus] = useState<Status>(paramsValid ? 'idle' : 'error')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
async function handleConfirm() {
setStatus('loading')
const { error } = await supabase.auth.verifyOtp({
type: 'invite',
token_hash: tokenHash!,
})
if (error) {
setStatus('error')
setErrorMessage(error.message)
} else {
void navigate({ to: '/reset-password' })
}
}
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div
className="flex flex-col items-center gap-6 px-8 w-full max-w-sm"
style={{ animation: 'confirmFadeIn 0.4s ease-out both' }}
>
<style>{`
@keyframes confirmFadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
<img
src="/inquality-logo-color.png"
alt="InQuality"
className="h-12 object-contain"
/>
{status === 'error' ? (
<>
<div className="text-center">
<h1 className="text-xl font-bold text-slate-900 tracking-tight">
El link de invitación no es válido o ya fue utilizado.
</h1>
<p className="text-slate-500 mt-3 text-sm leading-relaxed">
Podría haber expirado (duración: 24 horas) o el link fue abierto más de una vez.
</p>
<p className="text-slate-500 mt-2 text-sm leading-relaxed font-medium">
Contactá a tu consultor InQuality para recibir una nueva invitación.
</p>
{errorMessage && (
<p className="text-xs text-slate-400 mt-3">Detalle: {errorMessage}</p>
)}
</div>
<Link to="/login" className="w-full">
<Button variant="outline" className="w-full">
Ir al login
</Button>
</Link>
</>
) : (
<>
<div className="text-center">
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">
Bienvenido a InQ ROI
</h1>
<p className="text-slate-500 mt-2 text-sm leading-relaxed">
Tu consultor te ha dado acceso a la plataforma.
</p>
<p className="text-slate-500 mt-1 text-sm leading-relaxed">
Hacé click para confirmar tu acceso y establecer tu contraseña.
</p>
</div>
<Button
onClick={handleConfirm}
disabled={status === 'loading'}
className="w-full"
>
{status === 'loading' ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Confirmando
</>
) : (
'Confirmar acceso'
)}
</Button>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/auth/AuthContext'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
export function ResetPasswordPage() {
const { clearPasswordReset } = useAuth()
const navigate = useNavigate()
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (password.length < 8) {
setError('La contraseña debe tener al menos 8 caracteres')
return
}
if (password !== confirm) {
setError('Las contraseñas no coinciden')
return
}
setLoading(true)
const { error: updateError } = await supabase.auth.updateUser({ password })
if (updateError) {
setError(updateError.message)
setLoading(false)
} else {
clearPasswordReset()
void navigate({ to: '/' })
}
}
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div
className="flex flex-col items-center gap-6 px-8 w-full max-w-sm"
style={{ animation: 'fadeIn 0.4s ease-out both' }}
>
<style>{`
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
<img
src="/inquality-logo-color.png"
alt="InQuality"
className="h-12 object-contain"
/>
<div className="text-center">
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Establecer contraseña</h1>
<p className="text-slate-500 mt-1 text-sm">Creá una contraseña para tu cuenta InQ ROI</p>
</div>
<form onSubmit={handleSubmit} className="w-full flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-password" className="text-sm text-slate-700">Nueva contraseña</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mínimo 8 caracteres"
disabled={loading}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="confirm-password" className="text-sm text-slate-700">Confirmar contraseña</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="Repetí la contraseña"
disabled={loading}
/>
</div>
{error && (
<p className="text-xs text-red-600">{error}</p>
)}
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Guardando…' : 'Establecer contraseña'}
</Button>
</form>
</div>
</div>
)
}

View File

@@ -4,13 +4,16 @@ import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { parseBpmnXml, extractActivityElements, extractGatewayElements, BpmnParseError } from '@/domain/bpmn-parser' import { parseBpmnXml, extractActivityElements, extractGatewayElements, BpmnParseError } from '@/domain/bpmn-parser'
import { activityRepo, gatewayRepo } from '@/persistence/repositories'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
import { supabaseGatewayRepo } from '@/persistence/supabase/gateway-repo'
import type { Process, Activity, GatewayConfig } from '@/domain/types' import type { Process, Activity, GatewayConfig } from '@/domain/types'
import { bpmnTypeToGatewayType } from '@/domain/types' import { bpmnTypeToGatewayType } from '@/domain/types'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { useAuth } from '@/auth/AuthContext'
import { AppFooter } from '@/components/AppFooter' import { AppFooter } from '@/components/AppFooter'
import { AppHeader } from '@/components/AppHeader' import { AppHeader } from '@/components/AppHeader'
import { RequireRole } from '@/components/RequireRole'
const SAMPLE_PROCESSES = [ const SAMPLE_PROCESSES = [
{ {
@@ -36,6 +39,7 @@ const SAMPLE_PROCESSES = [
export function ImportPage() { export function ImportPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { toast } = useToast() const { toast } = useToast()
const { user } = useAuth()
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
const [loadingSampleId, setLoadingSampleId] = useState<string | null>(null) const [loadingSampleId, setLoadingSampleId] = useState<string | null>(null)
@@ -77,6 +81,10 @@ export function ImportPage() {
updatedAt: now, updatedAt: now,
groupId: targetGroupId, groupId: targetGroupId,
tags: [], tags: [],
ownerId: user!.id,
updatedBy: user!.id,
orgId: user!.orgId ?? null,
areaId: null,
} }
const activityElements = extractActivityElements(graph) const activityElements = extractActivityElements(graph)
@@ -112,10 +120,20 @@ export function ImportPage() {
} }
}) })
await Promise.all([ await Promise.race([
supabaseProcessRepo.save(process), (async () => {
activityRepo.saveMany(activities), await supabaseProcessRepo.save(process, user!.id)
gatewayRepo.saveMany(gateways), await Promise.all([
supabaseActivityRepo.saveMany(activities, user!.id),
supabaseGatewayRepo.saveMany(gateways),
])
})(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error('Timeout: Supabase no respondió en 20s — verificá tu conexión')),
20_000,
)
),
]) ])
toast({ toast({
@@ -141,7 +159,7 @@ export function ImportPage() {
console.error(err) console.error(err)
} }
} }
}, [navigate, toast]) }, [navigate, toast, user])
const onDrop = useCallback((e: React.DragEvent) => { const onDrop = useCallback((e: React.DragEvent) => {
e.preventDefault() e.preventDefault()
@@ -176,6 +194,7 @@ export function ImportPage() {
} }
return ( return (
<RequireRole roles={['platform_admin', 'member', 'client_editor']}>
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col"> <div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
<AppHeader /> <AppHeader />
<div className="flex-1 flex flex-col items-center justify-center p-6"> <div className="flex-1 flex flex-col items-center justify-center p-6">
@@ -183,7 +202,7 @@ export function ImportPage() {
<div className="text-center mb-10"> <div className="text-center mb-10">
<div className="inline-flex items-center gap-2 text-primary text-sm font-medium mb-4 bg-primary/10 px-3 py-1 rounded-full"> <div className="inline-flex items-center gap-2 text-primary text-sm font-medium mb-4 bg-primary/10 px-3 py-1 rounded-full">
<FlaskConical className="h-3.5 w-3.5" /> <FlaskConical className="h-3.5 w-3.5" />
Process Cost Platform · MVP Fase 0 InQ ROI
</div> </div>
<h1 className="text-4xl font-bold text-slate-900 mb-3"> <h1 className="text-4xl font-bold text-slate-900 mb-3">
Cuantificá el costo operativo<br />de tus procesos Cuantificá el costo operativo<br />de tus procesos
@@ -247,8 +266,8 @@ export function ImportPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Procesos de ejemplo — visibles directamente */} {/* Procesos de ejemplo — solo para InQuality */}
<div className="mt-8 w-full max-w-lg"> {user?.platformRole !== 'client_editor' && <div className="mt-8 w-full max-w-lg">
<p className="text-xs text-slate-400 uppercase tracking-wide font-medium text-center mb-3"> <p className="text-xs text-slate-400 uppercase tracking-wide font-medium text-center mb-3">
O cargá un proceso de ejemplo con un click O cargá un proceso de ejemplo con un click
</p> </p>
@@ -282,14 +301,11 @@ export function ImportPage() {
) )
})} })}
</div> </div>
</div> </div>}
<p className="mt-8 text-xs text-slate-300">
100% en tu navegador · Sin backend · Sin registro · Datos guardados localmente
</p>
<AppFooter /> <AppFooter />
</div> </div>
</div> </div>
</RequireRole>
) )
} }

View File

@@ -1,13 +1,18 @@
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useMemo } from 'react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate, useLocation } from '@tanstack/react-router'
import { import {
Upload, Search, Plus, Building2, FolderX, GitBranch, Upload, Search, Building2, GitBranch,
Clock, TrendingUp, BarChart2, FileX2, X, Settings, Clock, TrendingUp, BarChart2, FileX2, X, Settings,
MoreVertical, Trash2, Share2, Lock, User,
ChevronDown, FolderOpen,
} from 'lucide-react' } from 'lucide-react'
import { useLibraryStore } from '@/store/library-store' import { useLibraryStore } from '@/store/library-store'
import { formatCurrency } from '@/lib/format' import { useAuth } from '@/auth/AuthContext'
import type { Process, ProcessGroup, Simulation } from '@/domain/types' import { supabaseProcessRepo, type ProcessWithUserNames } from '@/persistence/supabase/process-repo'
import { formatCurrency, formatSimulationTimestamp } from '@/lib/format'
import type { Process, Simulation } from '@/domain/types'
import { AppHeader } from '@/components/AppHeader' import { AppHeader } from '@/components/AppHeader'
import { AppFooter } from '@/components/AppFooter'
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -41,6 +46,48 @@ function matchesTags(p: Process, activeTags: string[]): boolean {
return activeTags.every((t) => p.tags.includes(t)) return activeTags.every((t) => p.tags.includes(t))
} }
// ─── Ownership Badge ──────────────────────────────────────────────────────────
function OwnershipBadge({ process, currentUserId }: { process: Process; currentUserId: string }) {
const isOwn = process.ownerId === currentUserId
return (
<div
className="flex-shrink-0 flex items-center"
title={isOwn ? 'Solo vos' : 'De otro consultor'}
>
{isOwn
? <Lock className="h-3 w-3 text-muted-foreground/60" />
: <User className="h-3 w-3 text-muted-foreground/60" />
}
</div>
)
}
// ─── Toast ────────────────────────────────────────────────────────────────────
function useToast() {
const [message, setMessage] = useState<string | null>(null)
function show(msg: string) {
setMessage(msg)
setTimeout(() => setMessage(null), 2800)
}
return { message, show }
}
function Toast({ message }: { message: string | null }) {
if (!message) return null
return (
<div
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2.5 rounded-lg text-[13px] font-medium text-white shadow-lg transition-all"
style={{ background: '#15803d' }}
>
{message}
</div>
)
}
// ─── KPI Badge ──────────────────────────────────────────────────────────────── // ─── KPI Badge ────────────────────────────────────────────────────────────────
function KpiBadge({ process, simulation }: { process: Process; simulation: Simulation | null | undefined }) { function KpiBadge({ process, simulation }: { process: Process; simulation: Simulation | null | undefined }) {
@@ -78,18 +125,37 @@ function KpiBadge({ process, simulation }: { process: Process; simulation: Simul
// ─── Process Card ───────────────────────────────────────────────────────────── // ─── Process Card ─────────────────────────────────────────────────────────────
function ProcessCard({ process, simulation, showGroupChip, groupName }: { function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, currentUserName, onShared }: {
process: Process process: ProcessWithUserNames
simulation: Simulation | null | undefined simulation: Simulation | null | undefined
showGroupChip?: boolean showGroupChip?: boolean
groupName?: string groupName?: string
onDelete?: () => void
currentUserId: string
currentUserName: string
onShared?: () => void
}) { }) {
const navigate = useNavigate() const navigate = useNavigate()
const [menuState, setMenuState] = useState<null | 'menu' | 'confirm'>(null)
const isOwn = process.ownerId === currentUserId
const updatedByMe = process.updaterName === currentUserName
const fullDate = formatSimulationTimestamp(process.updatedAt)
// 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 ( return (
<div <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" 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={() => navigate({ to: '/workspace/$processId', params: { processId: process.id } })} onClick={() => {
if (menuState) { setMenuState(null); return }
navigate({ to: '/workspace/$processId', params: { processId: process.id } })
}}
> >
{/* Ícono */} {/* Ícono */}
<div className="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center" <div className="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center"
@@ -99,10 +165,15 @@ function ProcessCard({ process, simulation, showGroupChip, groupName }: {
{/* Info central */} {/* Info central */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-[13px] font-medium truncate">{process.name || 'Sin nombre'}</p> <div className="flex items-center gap-1.5">
<div className="flex items-center gap-1 mt-0.5"> <p className="text-[13px] font-medium truncate">{process.name || 'Sin nombre'}</p>
<OwnershipBadge process={process} currentUserId={currentUserId} />
</div>
<div className="flex items-center gap-1 mt-0.5" title={`${fullDate.date} a las ${fullDate.time}`}>
<Clock className="h-3 w-3 text-muted-foreground" /> <Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-[11px] text-muted-foreground">Modificado {relativeDate(process.updatedAt)}</span> <span className="text-[11px] text-muted-foreground">
Actualizado por {updatedByMe ? 'vos' : process.updaterName} · {relativeDate(process.updatedAt)}
</span>
</div> </div>
{(process.tags.length > 0 || showGroupChip) && ( {(process.tags.length > 0 || showGroupChip) && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
@@ -125,143 +196,220 @@ function ProcessCard({ process, simulation, showGroupChip, groupName }: {
<div className="flex-shrink-0 min-w-[90px]"> <div className="flex-shrink-0 min-w-[90px]">
<KpiBadge process={process} simulation={simulation} /> <KpiBadge process={process} simulation={simulation} />
</div> </div>
</div>
)
}
// ─── Group Card ─────────────────────────────────────────────────────────────── {/* Menú de opciones (⋮) — en el flujo flex, a la derecha del KPI */}
{onDelete && (
function GroupCard({ group, processCount, lastUpdated, onClick }: { <div
group: ProcessGroup className="relative flex-shrink-0"
processCount: number onClick={(e) => e.stopPropagation()}
lastUpdated: number | null >
onClick: () => void
}) {
return (
<button
className="text-left w-full rounded-xl p-3 transition-all"
style={{
background: 'hsl(var(--card))',
border: '0.5px solid hsl(var(--border))',
borderLeft: '3px solid #F59845',
borderRadius: '0.5rem',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = 'hsl(var(--border) / 0.8)'; (e.currentTarget as HTMLButtonElement).style.borderLeftColor = '#F59845' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = 'hsl(var(--border))'; (e.currentTarget as HTMLButtonElement).style.borderLeftColor = '#F59845' }}
onClick={onClick}
>
<p className="text-2xl font-medium" style={{ color: '#F59845' }}>{processCount}</p>
<p className="text-[13px] font-medium truncate mt-0.5">{group.name}</p>
{lastUpdated && (
<p className="text-[11px] text-muted-foreground mt-1">
Actualizado {relativeDate(lastUpdated)}
</p>
)}
</button>
)
}
// ─── New Group Card ───────────────────────────────────────────────────────────
function NewGroupCard({ label, onCreate }: { label: string; onCreate: (name: string) => void }) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const confirm = useCallback(() => {
const trimmed = value.trim()
if (trimmed) onCreate(trimmed)
setValue('')
setEditing(false)
}, [value, onCreate])
const cancel = useCallback(() => {
setValue('')
setEditing(false)
}, [])
useEffect(() => {
if (editing) inputRef.current?.focus()
}, [editing])
if (editing) {
return (
<div className="rounded-xl p-3 transition-all"
style={{ border: '1.5px solid #F59845', background: 'hsl(var(--card))', borderRadius: '0.5rem' }}>
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') confirm()
if (e.key === 'Escape') cancel()
}}
placeholder={`Nombre del ${label.toLowerCase()}...`}
className="w-full text-[12px] bg-transparent border-none outline-none placeholder:text-muted-foreground"
/>
<div className="flex gap-1.5 mt-2">
<button <button
onClick={confirm} 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'}`}
className="text-[11px] px-2 py-0.5 rounded font-medium text-white" onClick={(e) => { e.stopPropagation(); setMenuState(menuState === 'menu' ? null : 'menu') }}
style={{ background: '#F59845' }} title="Opciones"
> >
Crear <MoreVertical className="h-3.5 w-3.5" />
</button>
<button
onClick={cancel}
className="text-[11px] px-2 py-0.5 rounded font-medium text-muted-foreground border border-border"
>
Cancelar
</button> </button>
{menuState === 'menu' && (
<div
className="absolute right-0 top-7 z-20 bg-card border border-border rounded-lg shadow-lg py-1 min-w-[160px]"
onClick={(e) => e.stopPropagation()}
>
{isOwn && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-foreground hover:bg-secondary transition-colors"
onClick={async (e) => {
e.stopPropagation()
await supabaseProcessRepo.setShared(process.id, true)
setMenuState(null)
onShared?.()
}}
>
<Share2 className="h-3.5 w-3.5" />
Compartir con equipo
</button>
)}
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-destructive hover:bg-destructive/10 transition-colors"
onClick={(e) => { e.stopPropagation(); setMenuState('confirm') }}
>
<Trash2 className="h-3.5 w-3.5" />
Eliminar
</button>
</div>
)}
{menuState === 'confirm' && (
<div
className="absolute right-0 top-6 z-20 bg-card border border-border rounded-lg shadow-lg p-3 min-w-[170px]"
onClick={(e) => e.stopPropagation()}
>
<p className="text-[12px] font-medium mb-2 text-foreground">¿Eliminar proceso?</p>
<p className="text-[11px] text-muted-foreground mb-3">Esta acción no se puede deshacer.</p>
<div className="flex gap-1.5">
<button
className="flex-1 text-[11px] px-2 py-1 rounded font-medium text-white transition-opacity hover:opacity-90"
style={{ background: '#ef4444' }}
onClick={(e) => { e.stopPropagation(); onDelete(); setMenuState(null) }}
>
Eliminar
</button>
<button
className="flex-1 text-[11px] px-2 py-1 rounded border border-border text-muted-foreground hover:bg-secondary transition-colors"
onClick={(e) => { e.stopPropagation(); setMenuState(null) }}
>
Cancelar
</button>
</div>
</div>
)}
</div> </div>
</div> )}
) </div>
}
return (
<button
className="text-left w-full rounded-xl p-3 flex flex-col items-center justify-center gap-1.5 transition-all group"
style={{ border: '0.5px dashed hsl(var(--border))', background: 'hsl(var(--card))', borderRadius: '0.5rem', minHeight: '80px' }}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = '#F59845'; (e.currentTarget as HTMLButtonElement).style.color = '#F59845' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = 'hsl(var(--border))'; (e.currentTarget as HTMLButtonElement).style.color = '' }}
onClick={() => setEditing(true)}
>
<Plus className="h-4 w-4 text-muted-foreground group-hover:text-[#F59845] transition-colors" />
<span className="text-[12px] text-muted-foreground group-hover:text-[#F59845] transition-colors">
Nuevo {label.toLowerCase()}
</span>
</button>
) )
} }
// ─── Home View ──────────────────────────────────────────────────────────────── // ─── Home View ────────────────────────────────────────────────────────────────
function HomeView({ function HomeView({
onNavigateToGroup,
onImport, onImport,
ownerFilter,
currentUserId,
currentUserName,
onShared,
}: { }: {
onNavigateToGroup: (groupId: string | null) => void onImport: () => void
onImport: (groupId?: string) => void ownerFilter: 'all' | 'mine'
currentUserId: string
currentUserName: string
onShared: () => void
}) { }) {
const { groups, processes, settings, createGroup } = useLibraryStore() const { groups, processes, isLoading, error, load } = useLibraryStore()
const { user } = useAuth()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [activeTags, setActiveTags] = useState<string[]>([]) const [activeTags, setActiveTags] = useState<string[]>([])
const cloud = tagCloudData(processes) const visibleProcesses = ownerFilter === 'mine'
const unclassifiedProcesses = processes.filter((p) => p.groupId == null) ? processes.filter((p) => p.ownerId === currentUserId)
: processes
const cloud = tagCloudData(visibleProcesses)
const isSearching = search.trim().length > 0 || activeTags.length > 0 const isSearching = search.trim().length > 0 || activeTags.length > 0
const [collapsedOrgs, setCollapsedOrgs] = useState<Set<string>>(new Set())
const orgGroups = useMemo(() => {
const byOrg = new Map<string, { orgId: string; orgName: string; processes: ProcessWithUserNames[] }>()
for (const p of visibleProcesses) {
const key = p.orgId ?? '__sin_org'
const orgName = p.orgName ?? 'Sin organización'
if (!byOrg.has(key)) byOrg.set(key, { orgId: key, orgName, processes: [] })
byOrg.get(key)!.processes.push(p)
}
return [...byOrg.values()].sort((a, b) => a.orgName.localeCompare(b.orgName))
}, [visibleProcesses])
function getAreaGroups(orgProcesses: ProcessWithUserNames[]) {
const byArea = new Map<string, { areaId: string; areaName: string; processes: ProcessWithUserNames[] }>()
for (const p of orgProcesses) {
const key = p.areaId ?? '__sin_area'
const areaName = p.areaName ?? 'Sin área'
if (!byArea.has(key)) byArea.set(key, { areaId: key, areaName, processes: [] })
byArea.get(key)!.processes.push(p)
}
return [...byArea.values()].sort((a, b) => {
if (a.areaId === '__sin_area') return 1
if (b.areaId === '__sin_area') return -1
return a.areaName.localeCompare(b.areaName)
})
}
function toggleOrg(orgId: string) {
setCollapsedOrgs(prev => {
const next = new Set(prev)
if (next.has(orgId)) next.delete(orgId)
else next.add(orgId)
return next
})
}
const filteredProcesses = isSearching const filteredProcesses = isSearching
? processes.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags)) ? visibleProcesses.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
: [] : []
function toggleTag(tag: string) { function toggleTag(tag: string) {
setActiveTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]) setActiveTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag])
} }
// Empty state: sin grupos ni procesos // Skeleton mientras carga y aún no hay datos (evita flash de empty state)
if (isLoading && groups.length === 0 && processes.length === 0) {
return (
<div className="space-y-5 animate-pulse">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 rounded-xl bg-slate-100" />
))}
</div>
<div className="space-y-2">
{[1, 2].map((i) => (
<div key={i} className="h-14 rounded-xl bg-slate-100" />
))}
</div>
</div>
)
}
// Error state: el load() falló o se colgó (timeout) — distinto de "biblioteca vacía"
if (error && groups.length === 0 && processes.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<Building2 className="h-16 w-16 text-destructive/40" />
<p className="text-base font-medium">No se pudo cargar la biblioteca</p>
<p className="text-[13px] text-muted-foreground max-w-sm text-center">{error}</p>
<button
onClick={() => load()}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
style={{ background: '#F59845' }}
>
Reintentar
</button>
</div>
)
}
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
// Empty state: sin grupos ni procesos (ya terminó de cargar)
if (groups.length === 0 && processes.length === 0) { if (groups.length === 0 && processes.length === 0) {
if (isClientRole) {
if (user?.platformRole === 'client_editor') {
return (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<Building2 className="h-16 w-16 text-muted-foreground/30" />
<p className="text-base font-medium">Tu biblioteca está vacía</p>
<p className="text-[13px] text-muted-foreground">
Importá tu primer BPMN para empezar a cuantificar costos
</p>
<button
onClick={() => onImport()}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
style={{ background: '#F59845' }}
>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
</div>
)
}
return (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<Building2 className="h-16 w-16 text-muted-foreground/30" />
<p className="text-base font-medium">No tenés procesos asignados aún</p>
<p className="text-[13px] text-muted-foreground">Contactá a tu administrador para que te asigne acceso</p>
</div>
)
}
return ( return (
<div className="flex flex-col items-center justify-center py-24 gap-4"> <div className="flex flex-col items-center justify-center py-24 gap-4">
<Building2 className="h-16 w-16 text-muted-foreground/30" /> <Building2 className="h-16 w-16 text-muted-foreground/30" />
@@ -323,23 +471,23 @@ function HomeView({
{filteredProcesses.length === 0 && ( {filteredProcesses.length === 0 && (
<p className="text-[13px] text-muted-foreground py-4 text-center">Sin resultados para esa búsqueda.</p> <p className="text-[13px] text-muted-foreground py-4 text-center">Sin resultados para esa búsqueda.</p>
)} )}
{filteredProcesses.map((p) => { {filteredProcesses.map((p) => (
const group = groups.find((g) => g.id === p.groupId) <ProcessCardWithSim
return ( key={p.id}
<ProcessCardWithSim process={p}
key={p.id} showGroupChip
process={p} groupName={p.areaName ?? p.orgName ?? undefined}
showGroupChip currentUserId={currentUserId}
groupName={group?.name} currentUserName={currentUserName}
/> onShared={onShared}
) />
})} ))}
</div> </div>
)} )}
{/* Vista normal — grupos */} {/* Vista normal — org → área (solo roles internos) */}
{!isSearching && ( {!isSearching && !isClientRole && (
<> <div className="space-y-3" data-testid="groups-header">
{/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */} {/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */}
{cloud.length >= 3 && ( {cloud.length >= 3 && (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -373,58 +521,104 @@ function HomeView({
</div> </div>
)} )}
{/* Header sección grupos */} {/* Grupos org → área */}
<div className="flex items-center gap-2"> {orgGroups.map(({ orgId, orgName, processes: orgProcesses }) => {
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground"> const isCollapsed = collapsedOrgs.has(orgId)
{settings.groupLabel}s const areaGroups = getAreaGroups(orgProcesses)
</span>
<span className="text-[10px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground">
{groups.length}
</span>
</div>
{/* Grid de grupos */} return (
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}> <div key={orgId} id={`org-section-${orgId}`} className="border border-border rounded-xl overflow-hidden">
{groups.map((group) => { {/* Header de org — colapsable */}
const groupProcesses = processes.filter((p) => p.groupId === group.id) <button
const lastUpdated = groupProcesses.length > 0 onClick={() => toggleOrg(orgId)}
? Math.max(...groupProcesses.map((p) => p.updatedAt)) className="w-full flex items-center justify-between px-4 py-3 bg-secondary/40 hover:bg-secondary/60 transition-colors"
: null >
return ( <div className="flex items-center gap-2">
<GroupCard <Building2 className="h-4 w-4 text-muted-foreground" />
key={group.id} <span className="text-[13px] font-medium">{orgName}</span>
group={group} <span className="text-[11px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground">
processCount={groupProcesses.length} {orgProcesses.length}
lastUpdated={lastUpdated} </span>
onClick={() => onNavigateToGroup(group.id)} </div>
/> <ChevronDown
) className="h-4 w-4 text-muted-foreground transition-transform duration-200"
})} style={{ transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
<NewGroupCard />
label={settings.groupLabel} </button>
onCreate={(name) => createGroup(name)}
/>
</div>
{/* Sin clasificar */} {/* Contenido — animado */}
{unclassifiedProcesses.length > 0 && ( <div
<button className="transition-all duration-200 overflow-hidden"
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all hover:border-border/80" style={{ maxHeight: isCollapsed ? 0 : '9999px', opacity: isCollapsed ? 0 : 1 }}
style={{ border: '0.5px dashed hsl(var(--border))', background: 'hsl(var(--card))', borderRadius: '0.5rem' }} >
onClick={() => onNavigateToGroup(null)} <div className="p-3 space-y-3">
> {areaGroups.map(({ areaId, areaName, processes: areaProcesses }) => (
<FolderX className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <div key={areaId}>
<div className="flex-1 min-w-0"> {/* Sub-header de área: visible si hay >1 área o si el área tiene nombre */}
<span className="text-[13px] font-medium text-secondary-foreground"> {(areaGroups.length > 1 || areaId !== '__sin_area') && (
Procesos sin {settings.groupLabel.toLowerCase()} asignado <p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-2 flex items-center gap-1.5">
</span> <FolderOpen className="h-3 w-3" />
{areaName}
<span className="normal-case tracking-normal font-normal">
· {areaProcesses.length}
</span>
</p>
)}
<div className="space-y-2">
{areaProcesses.map((p) => (
<ProcessCardWithSim
key={p.id}
process={p}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
))}
</div>
</div>
))}
</div>
</div>
</div> </div>
<span className="text-[11px] text-muted-foreground flex-shrink-0"> )
{unclassifiedProcesses.length} proceso{unclassifiedProcesses.length !== 1 ? 's' : ''} })}
</span>
</button> {/* Empty state: admin sin procesos */}
{orgGroups.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Building2 className="h-12 w-12 text-muted-foreground/30" />
<p className="text-[13px] font-medium text-secondary-foreground">
Tu biblioteca está vacía
</p>
<p className="text-[13px] text-muted-foreground">
Importá tu primer BPMN para empezar
</p>
<button
onClick={() => onImport()}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white"
style={{ background: '#F59845' }}
>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
</div>
)} )}
</> </div>
)}
{/* Vista cliente: lista plana de procesos (sin grupos, RLS garantiza que solo ven los suyos) */}
{!isSearching && isClientRole && (
<div className="space-y-2">
{visibleProcesses.map((p) => (
<ProcessCardWithSim
key={p.id}
process={p}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
))}
</div>
)} )}
</div> </div>
) )
@@ -432,12 +626,15 @@ function HomeView({
// ─── ProcessCardWithSim (carga simulación lazy) ─────────────────────────────── // ─── ProcessCardWithSim (carga simulación lazy) ───────────────────────────────
function ProcessCardWithSim({ process, showGroupChip, groupName }: { function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, currentUserName, onShared }: {
process: Process process: ProcessWithUserNames
showGroupChip?: boolean showGroupChip?: boolean
groupName?: string groupName?: string
currentUserId: string
currentUserName: string
onShared: () => void
}) { }) {
const { getLatestSimulation } = useLibraryStore() const { getLatestSimulation, deleteProcess } = useLibraryStore()
const [simulation, setSimulation] = useState<Simulation | null | undefined>(undefined) const [simulation, setSimulation] = useState<Simulation | null | undefined>(undefined)
useEffect(() => { useEffect(() => {
@@ -448,7 +645,18 @@ function ProcessCardWithSim({ process, showGroupChip, groupName }: {
return () => { cancelled = true } return () => { cancelled = true }
}, [process.id, getLatestSimulation]) }, [process.id, getLatestSimulation])
return <ProcessCard process={process} simulation={simulation} showGroupChip={showGroupChip} groupName={groupName} /> return (
<ProcessCard
process={process}
simulation={simulation}
showGroupChip={showGroupChip}
groupName={groupName}
onDelete={() => deleteProcess(process.id)}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
)
} }
// ─── Group View ─────────────────────────────────────────────────────────────── // ─── Group View ───────────────────────────────────────────────────────────────
@@ -456,16 +664,29 @@ function ProcessCardWithSim({ process, showGroupChip, groupName }: {
function GroupView({ function GroupView({
groupId, groupId,
onBack, onBack,
currentUserId,
currentUserName,
ownerFilter,
onShared,
}: { }: {
groupId: string | null groupId: string | null
onBack: () => void onBack: () => void
currentUserId: string
currentUserName: string
ownerFilter: 'all' | 'mine'
onShared: () => void
}) { }) {
const { groups, processes, settings } = useLibraryStore() const { groups, processes, settings } = useLibraryStore()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const group = groupId ? groups.find((g) => g.id === groupId) : null const group = groupId ? groups.find((g) => g.id === groupId) : null
const groupName = group?.name ?? `Procesos sin ${settings.groupLabel.toLowerCase()}` const groupName = group?.name ?? `Procesos sin ${settings.groupLabel.toLowerCase()}`
const groupProcesses = processes
const baseProcesses = ownerFilter === 'mine'
? processes.filter((p) => p.ownerId === currentUserId)
: processes
const groupProcesses = baseProcesses
.filter((p) => groupId === null ? p.groupId == null : p.groupId === groupId) .filter((p) => groupId === null ? p.groupId == null : p.groupId === groupId)
.filter((p) => matchesSearch(p, search)) .filter((p) => matchesSearch(p, search))
@@ -512,7 +733,13 @@ function GroupView({
{/* Lista de procesos */} {/* Lista de procesos */}
<div className="space-y-2"> <div className="space-y-2">
{groupProcesses.map((p) => ( {groupProcesses.map((p) => (
<ProcessCardWithSim key={p.id} process={p} /> <ProcessCardWithSim
key={p.id}
process={p}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
))} ))}
</div> </div>
</div> </div>
@@ -523,23 +750,33 @@ function GroupView({
export function LibraryPage() { export function LibraryPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { load, isLoading, processes, groups, settings } = useLibraryStore() const { load, processes, isLoading } = useLibraryStore()
const { user, isProfileLoaded } = useAuth()
const [view, setView] = useState<'home' | 'group'>('home') const [view, setView] = useState<'home' | 'group'>('home')
const [activeGroupId, setActiveGroupId] = useState<string | null>(null) const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
const [transitioning, setTransitioning] = useState(false) const [transitioning, setTransitioning] = useState(false)
const [ownerFilter, setOwnerFilter] = useState<'all' | 'mine'>('all')
const { message: toastMessage, show: showToast } = useToast()
const location = useLocation()
const orgParam = useMemo(
() => new URLSearchParams(location.search).get('org') ?? undefined,
[location.search]
)
useEffect(() => { useEffect(() => {
if (!user) return // no fetchear hasta tener usuario confirmado (evita race condition con sesión Supabase)
load() load()
}, [load]) }, [load, user?.id])
function navigateToGroup(groupId: string | null) { // Scroll a la sección del org si venimos del workspace
setTransitioning(true) useEffect(() => {
setTimeout(() => { if (!orgParam || isLoading || processes.length === 0) return
setActiveGroupId(groupId) requestAnimationFrame(() => {
setView('group') const el = document.getElementById(`org-section-${orgParam}`)
setTransitioning(false) el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 150) })
} }, [orgParam, isLoading, processes.length])
function navigateHome() { function navigateHome() {
setTransitioning(true) setTransitioning(true)
@@ -550,16 +787,35 @@ export function LibraryPage() {
}, 150) }, 150)
} }
function handleImport(groupId?: string) { function handleImport() {
if (groupId) { navigate({ to: '/import' })
navigate({ to: '/import', search: { groupId } })
} else {
navigate({ to: '/import' })
}
} }
// Preflight fix: contar grupos reales en lugar del clientName heredado // Esperar a que syncProfileFromDB complete antes de renderizar UI rol-dependiente.
const totalGroups = groups.length // Evita el flash donde 'guest' se muestra como platform_admin durante la ventana de sync.
if (!user || !isProfileLoaded) {
return (
<div className="min-h-screen bg-background">
<AppHeader />
<div className="max-w-4xl mx-auto px-6 py-8">
<div className="space-y-5 animate-pulse">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
{[1, 2, 3].map((i) => <div key={i} className="h-20 rounded-xl bg-slate-100" />)}
</div>
<div className="space-y-2">
{[1, 2].map((i) => <div key={i} className="h-14 rounded-xl bg-slate-100" />)}
</div>
</div>
</div>
</div>
)
}
const currentUserId = user.id
const currentUserName = user.name
const isClientRole = user.platformRole === 'client_editor' || user.platformRole === 'client_viewer'
const orgCount = new Set(processes.map(p => p.orgId).filter(Boolean)).size
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@@ -570,52 +826,87 @@ export function LibraryPage() {
<div> <div>
<h1 className="text-[18px] font-medium">Biblioteca de procesos</h1> <h1 className="text-[18px] font-medium">Biblioteca de procesos</h1>
<p className="text-[12px] text-muted-foreground mt-0.5"> <p className="text-[12px] text-muted-foreground mt-0.5">
{processes.length} proceso{processes.length !== 1 ? 's' : ''} · {totalGroups} {settings.groupLabel.toLowerCase()}{totalGroups !== 1 ? 's' : ''} {isClientRole
? `${processes.length} proceso${processes.length !== 1 ? 's' : ''}`
: `${processes.length} proceso${processes.length !== 1 ? 's' : ''} · ${orgCount} cliente${orgCount !== 1 ? 's' : ''}`
}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button {/* Toggle Todos / Míos */}
onClick={() => navigate({ to: '/settings' })} <div className="flex rounded-lg border border-border overflow-hidden text-[12px]">
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors" <button
title="Configuración" className="px-3 py-1.5 font-medium transition-colors"
> style={ownerFilter === 'all'
<Settings className="h-4 w-4" /> ? { background: '#F59845', color: 'white' }
</button> : { background: 'transparent', color: 'hsl(var(--muted-foreground))' }
<button }
onClick={() => handleImport(view === 'group' && activeGroupId ? activeGroupId : undefined)} onClick={() => setOwnerFilter('all')}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90" >
style={{ background: '#F59845' }} Todos
> </button>
<Upload className="h-4 w-4" /> <button
Importar BPMN className="px-3 py-1.5 font-medium transition-colors border-l border-border"
</button> style={ownerFilter === 'mine'
? { background: '#F59845', color: 'white' }
: { background: 'transparent', color: 'hsl(var(--muted-foreground))' }
}
onClick={() => setOwnerFilter('mine')}
>
Míos
</button>
</div>
{!isClientRole && (
<button
onClick={() => navigate({ to: '/settings' })}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
title="Configuración"
>
<Settings className="h-4 w-4" />
</button>
)}
{user.platformRole !== 'client_viewer' && (
<button
onClick={handleImport}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
style={{ background: '#F59845' }}
>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
)}
</div> </div>
</div> </div>
{/* Contenido con transición */} {/* Contenido — sin spinner bloqueante: skeleton dentro de HomeView */}
{isLoading ? ( <div
<div className="flex items-center justify-center py-24"> className="transition-all duration-150"
<div className="h-6 w-6 rounded-full border-2 border-primary border-t-transparent animate-spin" /> style={{ opacity: transitioning ? 0 : 1, transform: transitioning ? 'translateY(6px)' : 'translateY(0)' }}
</div> >
) : ( {view === 'home' ? (
<div <HomeView
className="transition-all duration-150" onImport={handleImport}
style={{ opacity: transitioning ? 0 : 1, transform: transitioning ? 'translateY(6px)' : 'translateY(0)' }} ownerFilter={ownerFilter}
> currentUserId={currentUserId}
{view === 'home' ? ( currentUserName={currentUserName}
<HomeView onShared={() => showToast('Proceso compartido con el equipo')}
onNavigateToGroup={navigateToGroup} />
onImport={handleImport} ) : (
/> <GroupView
) : ( groupId={activeGroupId}
<GroupView onBack={navigateHome}
groupId={activeGroupId} currentUserId={currentUserId}
onBack={navigateHome} currentUserName={currentUserName}
/> ownerFilter={ownerFilter}
)} onShared={() => showToast('Proceso compartido con el equipo')}
</div> />
)} )}
</div>
<AppFooter />
</div> </div>
<Toast message={toastMessage} />
</div> </div>
) )
} }

View File

@@ -1,4 +1,14 @@
import { useState } from 'react'
import { z } from 'zod'
import { useAuth } from '@/auth/AuthContext' import { useAuth } from '@/auth/AuthContext'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
const loginSchema = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(1, 'La contraseña es requerida'),
})
function GoogleIcon() { function GoogleIcon() {
return ( return (
@@ -12,12 +22,39 @@ function GoogleIcon() {
} }
export function LoginPage() { export function LoginPage() {
const { signIn, loading } = useAuth() const { signIn, signInWithEmailPassword, loading } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [formError, setFormError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault()
setFormError(null)
const result = loginSchema.safeParse({ email, password })
if (!result.success) {
setFormError(result.error.issues[0].message)
return
}
setIsSubmitting(true)
const { error } = await signInWithEmailPassword(email, password)
if (error) {
setFormError(
error === 'Invalid login credentials'
? 'Email o contraseña incorrectos'
: error
)
setIsSubmitting(false)
}
// En login exitoso no resetear isSubmitting — el router redirige y desmonta el componente
}
return ( return (
<div className="min-h-screen bg-white flex items-center justify-center"> <div className="min-h-screen bg-white flex items-center justify-center">
<div <div
className="flex flex-col items-center gap-6 px-8" className="flex flex-col items-center gap-6 px-8 w-full max-w-sm"
style={{ animation: 'loginFadeIn 0.5s ease-out both' }} style={{ animation: 'loginFadeIn 0.5s ease-out both' }}
> >
<style>{` <style>{`
@@ -40,16 +77,63 @@ export function LoginPage() {
<button <button
onClick={signIn} onClick={signIn}
disabled={loading} disabled={loading || isSubmitting}
className="flex items-center gap-3 px-6 py-3 rounded-xl font-semibold text-white text-sm shadow-sm transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed" className="w-full flex items-center justify-center gap-3 px-6 py-3 rounded-xl font-semibold text-white text-sm shadow-sm transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed"
style={{ backgroundColor: '#F59845' }} style={{ backgroundColor: '#F59845' }}
> >
<GoogleIcon /> <GoogleIcon />
Iniciar sesión con Google Continuar con Google
</button> </button>
<p className="text-xs text-slate-400"> <div className="w-full flex items-center gap-3">
Solo para usuarios con cuenta @inquality.com.py <div className="flex-1 h-px bg-slate-200" />
<span className="text-xs text-slate-400">o</span>
<div className="flex-1 h-px bg-slate-200" />
</div>
<form onSubmit={handleEmailLogin} className="w-full flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="email" className="text-sm text-slate-700">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="usuario@empresa.com"
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password" className="text-sm text-slate-700">Contraseña</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isSubmitting}
/>
</div>
{formError && (
<p className="text-xs text-red-600">{formError}</p>
)}
<Button
type="submit"
disabled={isSubmitting || loading}
className="w-full"
>
{isSubmitting ? 'Ingresando…' : 'Ingresar'}
</Button>
</form>
<p className="text-xs text-slate-400 text-center">
Los usuarios externos acceden por invitación.<br />
Si no recibiste una, contactá a tu consultor InQuality.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUpDown, ArrowUp, ArrowDown, ChevronRight } from 'lucide-react' import { ArrowUpDown, ArrowUp, ArrowDown, ChevronRight } from 'lucide-react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { activityColor } from '@/lib/colors' 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) const hasBreakdown = !(act.resourceCostBreakdown.length === 0 && act.expectedDirectCost === 0)
return ( return (
<> <Fragment key={act.activityId}>
<TableRow <TableRow
key={act.activityId}
ref={(el) => { if (el) rowRefs.current.set(act.bpmnElementId, el) }} ref={(el) => { if (el) rowRefs.current.set(act.bpmnElementId, el) }}
onClick={() => onRowClick(act.bpmnElementId)} onClick={() => onRowClick(act.bpmnElementId)}
className={`cursor-pointer ${isHighlighted ? 'bg-primary/5 ring-1 ring-inset ring-primary/20' : ''}`} 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> </TableCell>
</TableRow> </TableRow>
)} )}
</> </Fragment>
) )
})} })}
</TableBody> </TableBody>

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Bot, ArrowUpDown } from 'lucide-react' import { Bot, ArrowUpDown } from 'lucide-react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { formatCurrency, formatPercent } from '@/lib/format' import { formatCurrency, formatPercent } from '@/lib/format'
import type { ActivitySimResult } from '@/domain/types' import type { ActivitySimResult } from '@/domain/types'
@@ -12,8 +13,12 @@ interface ComparisonRow {
savings: number savings: number
savingsPercent: number savingsPercent: number
automatable: boolean automatable: boolean
expectedSavings: number | null
efficiency: number | null
} }
type SortBy = 'savings' | 'expectedSavings' | 'efficiency'
interface ComparisonTableProps { interface ComparisonTableProps {
perActivityActual: ActivitySimResult[] perActivityActual: ActivitySimResult[]
perActivityAutomated: ActivitySimResult[] perActivityAutomated: ActivitySimResult[]
@@ -21,12 +26,46 @@ interface ComparisonTableProps {
currency: string currency: string
} }
function MetricCell({
value,
max,
format,
}: {
value: number | null
max: number
format: (v: number) => string
}) {
if (value === null) {
return (
<TableCell className="text-xs text-right">
<span className="text-slate-300"></span>
</TableCell>
)
}
return (
<TableCell className="text-xs text-right">
<div className="flex items-center justify-end gap-2">
<div className="w-14 h-1.5 rounded-full bg-slate-100 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500"
style={{ width: `${max > 0 ? (value / max) * 100 : 0}%` }}
/>
</div>
<span className="font-mono text-slate-700 text-xs w-14 text-right whitespace-nowrap">
{format(value)}
</span>
</div>
</TableCell>
)
}
export function ComparisonTable({ export function ComparisonTable({
perActivityActual, perActivityActual,
perActivityAutomated, perActivityAutomated,
automatableIds, automatableIds,
currency, currency,
}: ComparisonTableProps) { }: ComparisonTableProps) {
const [sortBy, setSortBy] = useState<SortBy>('efficiency')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const automatedByActivityId = useMemo(() => { const automatedByActivityId = useMemo(() => {
@@ -36,25 +75,53 @@ export function ComparisonTable({
}, [perActivityAutomated]) }, [perActivityAutomated])
const rows: ComparisonRow[] = useMemo(() => { const rows: ComparisonRow[] = useMemo(() => {
return perActivityActual const built = perActivityActual.map((act) => {
.map((act) => { const automated = automatedByActivityId.get(act.activityId)
const automated = automatedByActivityId.get(act.activityId) const automatedCost = automated?.expectedTotalCost ?? act.expectedTotalCost
const automatedCost = automated?.expectedTotalCost ?? act.expectedTotalCost const savings = act.expectedTotalCost - automatedCost
const savings = act.expectedTotalCost - automatedCost const savingsPercent =
const savingsPercent = act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0 const automatable = automatableIds.has(act.bpmnElementId)
return {
activityId: act.activityId, const expectedSavings =
activityName: act.activityName, automatable && savings > 0 ? savings * act.executionProbability : null
actualCost: act.expectedTotalCost,
automatedCost, const efficiency =
savings, automatable && savings > 0 && act.executionTimeMinutes > 0
savingsPercent, ? (savings * act.executionProbability) / act.executionTimeMinutes
automatable: automatableIds.has(act.bpmnElementId), : null
}
}) return {
.sort((a, b) => (b.savings - a.savings) * (sortDir === 'desc' ? 1 : -1)) activityId: act.activityId,
}, [perActivityActual, automatedByActivityId, automatableIds, sortDir]) activityName: act.activityName,
actualCost: act.expectedTotalCost,
automatedCost,
savings,
savingsPercent,
automatable,
expectedSavings,
efficiency,
}
})
return built.sort((a, b) => {
const val = (row: ComparisonRow) => {
if (sortBy === 'efficiency') return row.efficiency ?? -Infinity
if (sortBy === 'expectedSavings') return row.expectedSavings ?? -Infinity
return row.savings
}
return (val(b) - val(a)) * (sortDir === 'desc' ? 1 : -1)
})
}, [perActivityActual, automatedByActivityId, automatableIds, sortBy, sortDir])
const maxExpectedSavings = Math.max(
...rows.filter((r) => r.expectedSavings !== null).map((r) => r.expectedSavings as number),
0
)
const maxEfficiency = Math.max(
...rows.filter((r) => r.efficiency !== null).map((r) => r.efficiency as number),
0
)
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
@@ -64,6 +131,14 @@ export function ComparisonTable({
) )
} }
function toggleSort(col: SortBy) {
if (sortBy === col) setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'))
else {
setSortBy(col)
setSortDir('desc')
}
}
return ( return (
<div className="rounded-xl border border-inq-orange/20 overflow-hidden"> <div className="rounded-xl border border-inq-orange/20 overflow-hidden">
<Table> <Table>
@@ -75,14 +150,50 @@ export function ComparisonTable({
<TableHead className="text-xs text-right text-amber-800/70 font-medium uppercase tracking-wide">Costo automatizado</TableHead> <TableHead className="text-xs text-right text-amber-800/70 font-medium uppercase tracking-wide">Costo automatizado</TableHead>
<TableHead <TableHead
className="text-xs text-right cursor-pointer select-none text-amber-800/70 font-medium uppercase tracking-wide hover:text-amber-900" className="text-xs text-right cursor-pointer select-none text-amber-800/70 font-medium uppercase tracking-wide hover:text-amber-900"
onClick={() => setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'))} onClick={() => toggleSort('savings')}
> >
<span className="inline-flex items-center justify-end gap-1"> <span className="inline-flex items-center justify-end gap-1">
Ahorro Ahorro
<ArrowUpDown className="h-3 w-3 opacity-40" /> <ArrowUpDown className={`h-3 w-3 ${sortBy === 'savings' ? 'opacity-80' : 'opacity-40'}`} />
</span> </span>
</TableHead> </TableHead>
<TableHead className="text-xs text-right text-amber-800/70 font-medium uppercase tracking-wide">% ahorro</TableHead> <TableHead className="text-xs text-right text-amber-800/70 font-medium uppercase tracking-wide">% ahorro</TableHead>
<TableHead
className="text-xs text-right cursor-pointer select-none text-amber-800/70 font-medium uppercase tracking-wide hover:text-amber-900"
onClick={() => toggleSort('expectedSavings')}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center justify-end gap-1">
Ahorro esperado
<ArrowUpDown className={`h-3 w-3 ${sortBy === 'expectedSavings' ? 'opacity-80' : 'opacity-40'}`} />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-52 text-xs text-center">
Ahorro por instancia ponderado por probabilidad de ejecución de la actividad
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableHead>
<TableHead
className="text-xs text-right cursor-pointer select-none text-amber-800/70 font-medium uppercase tracking-wide hover:text-amber-900"
onClick={() => toggleSort('efficiency')}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center justify-end gap-1">
Eficiencia
<ArrowUpDown className={`h-3 w-3 ${sortBy === 'efficiency' ? 'opacity-80' : 'opacity-40'}`} />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-52 text-xs text-center">
Ahorro esperado dividido por tiempo de ejecución prioriza actividades con mayor retorno por minuto de proceso
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableHead>
</tr> </tr>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -124,6 +235,16 @@ export function ComparisonTable({
? (row.savings > 0 ? '+' : '') + formatPercent(row.savingsPercent) ? (row.savings > 0 ? '+' : '') + formatPercent(row.savingsPercent)
: '—'} : '—'}
</TableCell> </TableCell>
<MetricCell
value={row.expectedSavings}
max={maxExpectedSavings}
format={(v) => formatCurrency(v, currency)}
/>
<MetricCell
value={row.efficiency}
max={maxEfficiency}
format={(v) => `${v.toFixed(2)} ${currency}/min`}
/>
</TableRow> </TableRow>
) )
})} })}

View File

@@ -4,10 +4,11 @@ import { formatCurrency } from '@/lib/format'
import type { RoiResult } from '@/domain/roi' import type { RoiResult } from '@/domain/roi'
interface CumulativeSavingsChartProps { interface CumulativeSavingsChartProps {
roi: RoiResult roi: RoiResult // caso ajustado (o base si no hay ajuste)
horizonYears: number horizonYears: number
currency: string currency: string
investment: number investment: number // inversión ajustada (usada para cálculo de valores del eje Y)
roiBase?: RoiResult // caso base original — cuando presente, se muestra como línea secundaria
} }
export function CumulativeSavingsChart({ export function CumulativeSavingsChart({
@@ -15,6 +16,7 @@ export function CumulativeSavingsChart({
horizonYears, horizonYears,
currency, currency,
investment, investment,
roiBase,
}: CumulativeSavingsChartProps) { }: CumulativeSavingsChartProps) {
const option = useMemo(() => { const option = useMemo(() => {
const years = Array.from({ length: horizonYears + 1 }, (_, i) => i) const years = Array.from({ length: horizonYears + 1 }, (_, i) => i)
@@ -45,17 +47,111 @@ export function CumulativeSavingsChart({
lineStyle: { color: '#94a3b8', type: 'solid', width: 1 }, lineStyle: { color: '#94a3b8', type: 'solid', width: 1 },
}) })
// Caso base (sliders de sensibilidad activos) — recupera la inversión original a partir
// de cumulativeSavingsHorizon (= annualSavings × horizonYears investment), sin necesidad
// de una prop adicional: roiBase ya contiene toda la información necesaria.
let baseValues: number[] | null = null
if (roiBase) {
const investmentBase = roiBase.annualSavings * horizonYears - roiBase.cumulativeSavingsHorizon
baseValues = years.map((y) => roiBase.annualSavings * y - investmentBase)
}
// Fill-between: dos series stackeadas (lower invisible + diff coloreado) para sombrear
// exactamente el área entre la línea base y la ajustada, sin importar cuál es mayor.
const lowerValues = baseValues ? values.map((v, i) => Math.min(v, baseValues![i])) : null
const diffValues = baseValues ? values.map((v, i) => Math.abs(v - baseValues![i])) : null
const allValues = baseValues ? [...values, ...baseValues] : values
const yMin = Math.min(...allValues, 0)
const yMax = Math.max(...allValues, 0)
// Margen del 10% para que las líneas no queden pegadas al borde del gráfico
const yPad = Math.max((yMax - yMin) * 0.1, 1)
const series: Record<string, unknown>[] = []
if (lowerValues && diffValues) {
series.push({
name: 'fill-lower',
type: 'line',
data: years.map((y, i) => [y, lowerValues![i]]),
stack: 'fill-band',
symbol: 'none',
lineStyle: { opacity: 0 },
silent: true,
tooltip: { show: false },
})
series.push({
name: 'fill-diff',
type: 'line',
data: years.map((y, i) => [y, diffValues![i]]),
stack: 'fill-band',
symbol: 'none',
lineStyle: { opacity: 0 },
areaStyle: { color: '#F59845', opacity: 0.08 },
silent: true,
tooltip: { show: false },
})
}
if (baseValues) {
series.push({
name: 'Base',
type: 'line',
data: years.map((y, i) => [y, baseValues![i]]),
smooth: false,
symbol: 'circle',
symbolSize: 5,
lineStyle: { color: '#94a3b8', width: 1.5, type: 'dashed' },
itemStyle: { color: '#94a3b8' },
})
}
series.push({
name: 'Ajustado',
type: 'line',
data: years.map((y, i) => [y, values[i]]),
smooth: false,
lineStyle: { color: '#F59845', width: 2.5 },
areaStyle: baseValues
? undefined
: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(245,152,69,0.18)' },
{ offset: 1, color: 'rgba(245,152,69,0.02)' },
],
},
},
itemStyle: { color: '#F59845' },
symbolSize: 6,
markLine: {
silent: true,
symbol: ['none', 'none'],
data: markLineData,
},
})
return { return {
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
formatter: (params: unknown[]) => { formatter: (params: unknown[]) => {
const p = params[0] as { dataIndex: number; value: [number, number] } const typedParams = params as Array<{ seriesName: string; value: [number, number] }>
const year = p.value[0] const adjustedP = typedParams.find((p) => p.seriesName === 'Ajustado')
const val = p.value[1] const baseP = typedParams.find((p) => p.seriesName === 'Base')
return `<div style="font-size:12px;line-height:1.6"> if (!adjustedP) return ''
const year = adjustedP.value[0]
const val = adjustedP.value[1]
let html = `<div style="font-size:12px;line-height:1.6">
<div style="font-weight:600;margin-bottom:4px">Año ${year}</div> <div style="font-weight:600;margin-bottom:4px">Año ${year}</div>
<div>Acumulado neto: <strong style="color:${val >= 0 ? '#16A34A' : '#EF4444'}">${formatCurrency(val, currency)}</strong></div> <div>Acumulado ajustado: <strong style="color:${val >= 0 ? '#16A34A' : '#EF4444'}">${formatCurrency(val, currency)}</strong></div>`
</div>` if (baseP) {
const baseVal = baseP.value[1]
html += `<div>Acumulado base: <strong style="color:${baseVal >= 0 ? '#16A34A' : '#EF4444'}">${formatCurrency(baseVal, currency)}</strong></div>`
}
html += '</div>'
return html
}, },
}, },
grid: { left: 16, right: 24, top: 16, bottom: 8, containLabel: true }, grid: { left: 16, right: 24, top: 16, bottom: 8, containLabel: true },
@@ -72,6 +168,8 @@ export function CumulativeSavingsChart({
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
min: baseValues ? yMin - yPad : undefined,
max: baseValues ? yMax + yPad : undefined,
axisLabel: { axisLabel: {
fontSize: 11, fontSize: 11,
formatter: (v: number) => { formatter: (v: number) => {
@@ -85,33 +183,9 @@ export function CumulativeSavingsChart({
}, },
splitLine: { lineStyle: { color: '#f1f5f9' } }, splitLine: { lineStyle: { color: '#f1f5f9' } },
}, },
series: [ series,
{
type: 'line',
data: years.map((y, i) => [y, values[i]]),
smooth: false,
lineStyle: { color: '#F59845', width: 2.5 },
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(245,152,69,0.18)' },
{ offset: 1, color: 'rgba(245,152,69,0.02)' },
],
},
},
itemStyle: { color: '#F59845' },
symbolSize: 6,
markLine: {
silent: true,
symbol: ['none', 'none'],
data: markLineData,
},
},
],
} }
}, [roi, horizonYears, currency, investment]) }, [roi, horizonYears, currency, investment, roiBase])
return <ReactECharts option={option} style={{ height: 280 }} notMerge /> return <ReactECharts option={option} style={{ height: 280 }} notMerge />
} }

View File

@@ -151,9 +151,12 @@ export const HeatmapCanvas = forwardRef<HeatmapCanvasHandle, HeatmapCanvasProps>
setIsImported(false) setIsImported(false)
const eventBus = viewer.get('eventBus') as any const eventBus = viewer.get('eventBus') as any
let cancelled = false
const onImportDone = () => { const onImportDone = () => {
eventBus.off('import.done', 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 const canvas = viewer.get('canvas') as any
try { canvas.zoom('fit-viewport', 'auto') } catch { /* canvas puede no estar listo */ } try { canvas.zoom('fit-viewport', 'auto') } catch { /* canvas puede no estar listo */ }
isImportedRef.current = true isImportedRef.current = true
@@ -163,12 +166,14 @@ export const HeatmapCanvas = forwardRef<HeatmapCanvasHandle, HeatmapCanvasProps>
eventBus.on('import.done', onImportDone) eventBus.on('import.done', onImportDone)
viewer.importXML(xml).catch((err: unknown) => { 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). // 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. // '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) console.warn('HeatmapCanvas importXML error (diagrama puede estar parcialmente renderizado):', err)
}) })
return () => { return () => {
cancelled = true
eventBus.off('import.done', onImportDone) eventBus.off('import.done', onImportDone)
} }
}, [xml]) }, [xml])

View File

@@ -1,5 +1,5 @@
import { formatCurrency, formatPercent } from '@/lib/format' import { formatCurrency } from '@/lib/format'
import { heatmapLegendBounds, HEATMAP_LOW_HEX, HEATMAP_MID_HEX, HEATMAP_HIGH_HEX } from '@/lib/colors' import { heatmapLegendBounds, HEATMAP_LOW_HEX, HEATMAP_MID_HEX, HEATMAP_HIGH_MID_HEX, HEATMAP_HIGH_HEX } from '@/lib/colors'
import type { HeatmapMode } from '@/lib/colors' import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types' import type { ActivitySimResult } from '@/domain/types'
@@ -12,8 +12,10 @@ interface HeatmapLegendProps {
export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProps) { export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProps) {
const bounds = heatmapLegendBounds(perActivity, mode) const bounds = heatmapLegendBounds(perActivity, mode)
// En modo percentile los extremos son rango (0 = más barata, 100 = más cara), no costo
// literal — el percentil no es lineal respecto al costo real.
function formatBound(value: number): string { function formatBound(value: number): string {
if (bounds.unit === 'percent') return formatPercent(value, value % 1 === 0 ? 0 : 1) if (bounds.unit === 'rank') return `P${Math.round(value)}`
return formatCurrency(value, currency) return formatCurrency(value, currency)
} }
@@ -21,7 +23,7 @@ export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProp
<div className="flex items-center gap-3 mt-3"> <div className="flex items-center gap-3 mt-3">
<span className="text-xs text-slate-500 shrink-0">Bajo costo</span> <span className="text-xs text-slate-500 shrink-0">Bajo costo</span>
<div className="flex-1 h-3 rounded-full" style={{ <div className="flex-1 h-3 rounded-full" style={{
background: `linear-gradient(to right, ${HEATMAP_LOW_HEX}, ${HEATMAP_MID_HEX}, ${HEATMAP_HIGH_HEX})` background: `linear-gradient(to right, ${HEATMAP_LOW_HEX}, ${HEATMAP_MID_HEX}, ${HEATMAP_HIGH_MID_HEX}, ${HEATMAP_HIGH_HEX})`
}} /> }} />
<span className="text-xs text-slate-500 shrink-0">Alto costo</span> <span className="text-xs text-slate-500 shrink-0">Alto costo</span>
<div className="h-4 border-l border-slate-200 mx-1" /> <div className="h-4 border-l border-slate-200 mx-1" />

View File

@@ -8,20 +8,20 @@ interface HeatmapModeToggleProps {
} }
const MODE_LABELS: Record<HeatmapMode, { label: string; tip: string }> = { const MODE_LABELS: Record<HeatmapMode, { label: string; tip: string }> = {
relative: { percentile: {
label: 'Costo relativo (%)', label: 'Percentil',
tip: 'Colorea cada actividad en función de su % del costo total. Útil para ver proporciones.', tip: 'Colorea cada actividad según su posición relativa entre todas las actividades del proceso. No se distorsiona por outliers.',
}, },
absolute: { minmax: {
label: 'Costo absoluto', label: 'Min-max',
tip: 'Colorea según el costo esperado en la moneda del proceso. Útil para comparar magnitudes reales.', tip: '(costo mínimo) / (máximo mínimo). Refleja valores absolutos, pero se comprime si hay outliers extremos.',
}, },
} }
export function HeatmapModeToggle({ mode, onChange }: HeatmapModeToggleProps) { export function HeatmapModeToggle({ mode, onChange }: HeatmapModeToggleProps) {
return ( return (
<div className="inline-flex rounded-lg border border-slate-200 bg-slate-50 p-0.5 text-xs"> <div className="inline-flex rounded-lg border border-slate-200 bg-slate-50 p-0.5 text-xs">
{(['relative', 'absolute'] as HeatmapMode[]).map((m) => { {(['percentile', 'minmax'] as HeatmapMode[]).map((m) => {
const { label, tip } = MODE_LABELS[m] const { label, tip } = MODE_LABELS[m]
return ( return (
<Tooltip key={m}> <Tooltip key={m}>

Some files were not shown because too many files have changed in this diff Show More