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.
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: '' } }).
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.
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).
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.
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.
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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.
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?
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.
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.
- 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>