Compare commits
99 Commits
81a9424f7f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b839baf77 | |||
| 1032944f8b | |||
| 0257dd95ec | |||
| fdbdc42470 | |||
| 3044342ac1 | |||
| 85190aef0c | |||
| 789e9a34ea | |||
| bff71f0f29 | |||
| 70d35dad8b | |||
| 54306be1c2 | |||
| d776af5f24 | |||
| cc5563974a | |||
| c6c88501d1 | |||
| 1813a9396a | |||
| b149e1cd0c | |||
| 53f8a8d8cd | |||
| 6e60bf4def | |||
| fe33c910ac | |||
| b634d061b7 | |||
| e0b4607603 | |||
| de00e3a902 | |||
| aa0ab72f17 | |||
| cbf3a88e78 | |||
| b99cc75f80 | |||
| 27336fe0c7 | |||
| e8490a5863 | |||
| d6e7226842 | |||
| f9e886e12d | |||
| a3ff6ee545 | |||
| d64078d96a | |||
| 10e477cc45 | |||
| ec77a7fffa | |||
| 0426f67940 | |||
| 9277143822 | |||
| ee7cdf5981 | |||
| 7102d6ade6 | |||
| c92f4f087a | |||
| df5fb55696 | |||
| 6fee592e40 | |||
| f3f55a6857 | |||
| ee356e5de6 | |||
| cad8fbca4f | |||
| 9b89cb8669 | |||
| 1f84997794 | |||
| 074f726d62 | |||
| 6be9f6267f | |||
| ef9ec472d5 | |||
| bc9f70419c | |||
| 2922d4c2f3 | |||
| b117ad258d | |||
| b42cdc7e80 | |||
| bae8dc11c7 | |||
| 1e4dbc0bed | |||
| b9cd57d148 | |||
| dc610f8a1e | |||
| f47cf82abf | |||
| 5ca5d7ea31 | |||
| 485cc91d73 | |||
| e9073ae732 | |||
| bf200b72f9 | |||
| ecfb72c3f1 | |||
| db82be4e8c | |||
| c93d8e824f | |||
| 1aa1486d88 | |||
| aaf1f79519 | |||
| 985c655f6a | |||
| a1a8b1c651 | |||
| 7da39c7f1e | |||
| 7f1f83382d | |||
| 1a4650d186 | |||
| 69f744ae3f | |||
| 27ce96ca1e | |||
| 882edd230d | |||
| 1c5329915a | |||
| 3ebfd65a90 | |||
| 8142b07525 | |||
| 602957b6c9 | |||
| 3d3ae99125 | |||
| a626ff040c | |||
| 496c227c88 | |||
| dd361b1b12 | |||
| 224b3bc5e7 | |||
| 4a19eda1b2 | |||
| 735294db06 | |||
| 7ffab83caf | |||
| 6d14e64f33 | |||
| e8a4a1b3e0 | |||
| a65bec45c3 | |||
| 889929329e | |||
| a2763ab755 | |||
| 84daf82e65 | |||
| f77085c42d | |||
| 92597e108d | |||
| c738bc5489 | |||
| 892b8b3bc7 | |||
| 4f51c74717 | |||
| 5141a5c06c | |||
| c20624399c | |||
| b3b6fbe792 |
14
.env.example
14
.env.example
@@ -2,3 +2,17 @@
|
||||
# Obtener desde Supabase Dashboard → Project Settings → API
|
||||
VITE_SUPABASE_URL=https://<project-ref>.supabase.co
|
||||
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
2
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env.test
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -26,6 +27,7 @@ dist-ssr
|
||||
# Playwright outputs
|
||||
tests/e2e/__output__/
|
||||
test-results/
|
||||
tests/e2e/setup/auth-state.json
|
||||
|
||||
# Wrangler
|
||||
.wrangler/
|
||||
|
||||
73
BACKLOG.md
73
BACKLOG.md
@@ -20,8 +20,14 @@ Al cerrar un sprint, los ítems `[SPRINT-N]` se promocionan a `[SPRINT-(N+1)]` o
|
||||
|
||||
## Features de alta prioridad
|
||||
|
||||
### [BACKLOG] Indicador de simulación desactualizada
|
||||
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.
|
||||
### [RESOLVED] Ver procesos asociados a un recurso + navegar a ellos
|
||||
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
|
||||
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
|
||||
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
|
||||
Hoy requiere ir a configuración global. Mover edición inline al workspace.
|
||||
### [RESOLVED] Edición del nombre de proceso y cliente desde el 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
|
||||
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
|
||||
Hoy es visualización estática. Permitir interacción.
|
||||
|
||||
### [BACKLOG] Controles de zoom en canvas BPMN (fit-to-screen / reset zoom)
|
||||
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.
|
||||
### [RESOLVED] Controles de zoom en canvas BPMN (fit-to-screen / zoom-in / zoom-out)
|
||||
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.1x–4x). OK formal del director 2026-06-19.
|
||||
|
||||
### [BACKLOG] Detalle de recursos por actividad en el PDF
|
||||
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
|
||||
Cargar BPMN desde enlace directo (GitHub, Confluence, etc.).
|
||||
|
||||
### [BACKLOG] 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".
|
||||
### [RESOLVED] Columna "Eficiencia de automatización" en ComparisonTable
|
||||
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)
|
||||
|
||||
### [SPRINT-4] Login y multi-tenant
|
||||
Persistencia en la nube, colaboración entre múltiples usuarios. Promovido a Sprint 4 — ver `sprints/sprint-4/BRIEF.md`. Implementación: Supabase + Google Workspace OAuth, PostgreSQL, modelo de privacidad private/team.
|
||||
### [RESOLVED] Login y multi-tenant
|
||||
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
|
||||
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.
|
||||
|
||||
### [DEFERRED] Ícono de InQuality en header de PDFs
|
||||
Requiere assets de marca consolidados que aún no existen. Re-evaluar cuando InQuality tenga manual de marca completo.
|
||||
### [BACKLOG] Logo InQuality en header de PDFs
|
||||
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.
|
||||
|
||||
387
CHANGELOG.md
387
CHANGELOG.md
@@ -1,262 +1,129 @@
|
||||
# Changelog
|
||||
|
||||
Historial de cambios del proyecto. Formato basado en [Keep a Changelog](https://keepachangelog.com/es/).
|
||||
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
|
||||
modificaciones profundas tengan contexto suficiente.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased] — Sprint 1: ROI y Escenarios de Automatización
|
||||
|
||||
### Estado
|
||||
En construcción. Etapas 1–4 completas; Etapas 5–6 pendientes.
|
||||
|
||||
### Etapa 1 — Cimientos del dominio (completada)
|
||||
|
||||
**Nuevos campos en el modelo de dominio** (`src/domain/types.ts`):
|
||||
|
||||
- `Activity` gana: `automatable: boolean`, `automatedCostFixed: number`, `automatedTimeMinutes: number`
|
||||
- `Process` gana: `annualFrequency: number`, `analysisHorizonYears: number`, `automationInvestment: number`
|
||||
- Nuevo tipo `Scenario = 'actual' | 'automated'`
|
||||
- `SimulationInput` acepta `scenario?: Scenario` (opcional, default `'actual'` para retrocompatibilidad)
|
||||
- `Simulation` gana `resultAutomated?: SimulationResult` (opcional, para retrocompatibilidad con snapshots viejos)
|
||||
|
||||
**Migración Dexie v1 → v2** (`src/persistence/db.ts`):
|
||||
|
||||
- `version(2).upgrade()` popula defaults en registros existentes:
|
||||
- Activities: `automatable=false`, `automatedCostFixed=0`, `automatedTimeMinutes=0`
|
||||
- Processes: `annualFrequency=1000`, `analysisHorizonYears=3`, `automationInvestment=0`
|
||||
- Sin cambio de índices — solo nuevos campos con defaults seguros.
|
||||
|
||||
**Nuevo módulo de ROI** (`src/domain/roi.ts`):
|
||||
|
||||
- Función pura `calculateRoi(input: RoiInput): RoiResult`
|
||||
- Fórmulas nominales simples (sin VPN ni TIR — decisión de diseño ADR-014)
|
||||
- Edge cases explicitados: inversión cero, ahorro negativo, costo actual cero (evitar div/0)
|
||||
- `paybackMonths = Infinity` cuando no hay ahorro, `roiAnnualPercent = Infinity` cuando inversión=0 y ahorro>0
|
||||
|
||||
**Motor de simulación dual** (`src/domain/simulation.ts`):
|
||||
|
||||
- Acepta `scenario` en `SimulationInput`
|
||||
- En `'automated'`: actividades con `automatable=true` usan `automatedCostFixed`/`automatedTimeMinutes`
|
||||
- Las no-automatable mantienen sus costos originales en ambos escenarios
|
||||
- La probabilidad de ejecución NO cambia entre escenarios — solo costos y tiempos
|
||||
|
||||
**Simulation store refactorizado** (`src/store/simulation-store.ts`):
|
||||
|
||||
- `result` (single) → `resultActual + resultAutomated + lastSimulatedAt`
|
||||
- Nueva acción `persistResults(processId, actual, automated)`: guarda en Dexie + actualiza memoria en un solo paso atómico
|
||||
- Nueva acción `invalidateResults()`: pone ambos a null
|
||||
- Suscripción reactiva a `process-store`: cualquier cambio en `activities` o `currentProcess` invalida ambos resultados automáticamente
|
||||
- **Dependencia intencional documentada**: `simulation-store` importa `process-store` en una dirección (sin inversa → sin ciclo)
|
||||
|
||||
**useSimulate refactorizado** (`src/features/simulation/useSimulate.ts`):
|
||||
|
||||
- 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
|
||||
|
||||
**Infraestructura ESLint** (`eslint.config.js` — archivo nuevo):
|
||||
|
||||
- Primer config de ESLint del proyecto (antes no existía)
|
||||
- Regla `no-restricted-imports`: prohíbe importar `persistence/repositories` desde fuera de `store/`
|
||||
- Overrides para `store/**`, `features/import/**`, `features/report/useReportData.ts`, `tests/**`
|
||||
- Regla `react-hooks/set-state-in-effect: 'off'`: patrón de sync local state con useEffect es válido en este codebase
|
||||
- **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)
|
||||
|
||||
**Tests nuevos** (32 tests):
|
||||
|
||||
- `tests/domain/roi.test.ts`: 16 casos cubriendo happy path, inversión cero, ahorro negativo, NaN
|
||||
- `tests/integration/dexie-migration.test.ts`: 6 casos de persistencia de los nuevos campos con valores default y no-default
|
||||
- `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
|
||||
- `tests/domain/simulation.test.ts`: +4 casos de `scenario=automated` con costos en cero, sin NaN, probabilidad invariante
|
||||
- `tests/integration/bpmn-collaboration.test.ts`: 3 casos para BPMNs de colaboración con múltiples pools
|
||||
|
||||
**Archivos de test actualizados** (fixtures con nuevos campos):
|
||||
|
||||
- `tests/integration/simulation-engine.test.ts`
|
||||
- `tests/integration/gateway-validation.test.ts`
|
||||
- `tests/integration/indexeddb-persistence.test.ts`
|
||||
- `tests/features/report/components.test.tsx`
|
||||
- `tests/features/report/derivations.test.ts`
|
||||
- `tests/lib/export/measure-sizes.test.ts`
|
||||
- `tests/lib/export/csv-export.test.ts`
|
||||
- `tests/lib/export/pdf-export.test.ts`
|
||||
- `tests/setup.ts`: mocks globales `ResizeObserver` y `PointerEvent` para jsdom (necesario desde Sprint 1 para tests con Slider de Radix)
|
||||
|
||||
### Etapa 2 — UI de Configuración (completada)
|
||||
|
||||
**Switch component** (`src/components/ui/switch.tsx` — nuevo):
|
||||
|
||||
- Implementación Tailwind pura sin `@radix-ui/react-switch`
|
||||
- `role="switch"` + `aria-checked` + `aria-label` para accesibilidad
|
||||
- Transiciones CSS: `transition-colors duration-200` en el fondo, `transition-transform duration-200` en el knob
|
||||
- **Decisión**: no instalar `@radix-ui/react-switch` para no agregar dependencia — el comportamiento visual es idéntico
|
||||
|
||||
**ActivityPanel ampliado** (`src/features/workspace/ActivityPanel.tsx`):
|
||||
|
||||
- Nueva sección "Automatización" al pie del panel, separada con `<Separator>`
|
||||
- Toggle Switch (`automatable`) oculta/muestra los campos con `transition-all duration-200 overflow-hidden max-h-{0|80}`
|
||||
- **Limitación conocida**: el max-height trick no anima a la altura real del contenido. Ver TODO.md para mejora con `useRef + scrollHeight`
|
||||
- Inputs numéricos `automatedCostFixed` y `automatedTimeMinutes` con mismo patrón que los existentes (placeholder cuando valor=0)
|
||||
- Helper ámbar de transparencia metodológica:
|
||||
- Aparece cuando `automatable=true && cost===0 && time===0`
|
||||
- **Siempre está en el DOM** (no conditional render) — la visibilidad se controla con `opacity-0/100 + max-h-0/20 + transition-all duration-150`
|
||||
- `aria-hidden` refleja el estado de visibilidad
|
||||
- **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
|
||||
|
||||
**GlobalSettingsPanel ampliado** (`src/features/workspace/GlobalSettingsPanel.tsx`):
|
||||
|
||||
- Nueva sección "Análisis de Impacto" al pie del panel
|
||||
- `annualFrequency`: input numérico, min=1, sugerencias de referencia en texto helper
|
||||
- `analysisHorizonYears`: Slider de 1 a 10 años con valor en texto ("3 años")
|
||||
- `automationInvestment`: input numérico, default placeholder vacío cuando valor=0
|
||||
- La validación de rango de `analysisHorizonYears` (clamp 1-10) ocurre en `handleSave`, no en `onChange`
|
||||
|
||||
**BpmnCanvas ampliado** (`src/features/workspace/BpmnCanvas.tsx`):
|
||||
|
||||
- Nueva prop `automatableElementIds?: string[]`
|
||||
- `syncAutomationOverlays()`: callback memoizado con `useCallback([automatableElementIds])`
|
||||
- Limpia overlays anteriores con `overlays.remove({ type: 'automation-indicator' })`
|
||||
- Re-agrega un badge por cada elementId en la lista
|
||||
- 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 }`
|
||||
- Obtenido via `elementRegistry.get(elementId)` con fallback `width ?? 100`
|
||||
- **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
|
||||
- Con `left = width - 12`: badge left está 12px antes del borde derecho, su borde derecho queda 8px fuera → overlap visible en la esquina
|
||||
- **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
|
||||
- `pointer-events: none` para no interferir con clicks en el nodo
|
||||
- `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
|
||||
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]
|
||||
### Added
|
||||
- `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)
|
||||
- Edge Function `list-users`: lista usuarios con email desde auth.users vía service_role (Sprint 8)
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
### Changed
|
||||
- AdminLayout: guard useEffect + spinner → RequireRole wrapper (sin flash de contenido protegido) (Sprint 8)
|
||||
- ImportPage: guard useEffect → RequireRole wrapper (Sprint 8)
|
||||
- UsersPage: "Eliminar" (soft delete / revoke-user) → "Eliminar permanentemente" (hard delete / delete-user) (Sprint 8)
|
||||
|
||||
### Fixed
|
||||
- ImportPage: texto de marca corregido — "Process Cost Platform · MVP Fase 0" → "InQ ROI" (Sprint 8)
|
||||
- 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)
|
||||
- WorkspacePage: back button pasa ?org=orgId para scroll contextual al volver a biblioteca (Sprint 8)
|
||||
- LibraryPage: scroll automático a sección de org al recibir ?org param desde workspace (Sprint 8)
|
||||
- router.tsx: redirect de settingsRoute pasa search: { org: undefined } para compatibilidad con validateSearch (Sprint 8)
|
||||
|
||||
### Added (Etapas 1-4)
|
||||
- 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)
|
||||
- OrganizationDetailPage: tercer tab "Áreas" con CRUD completo — crear, renombrar, eliminar con confirmación (Sprint 8)
|
||||
- LibraryPage: agrupación org → área con headers colapsables para admin (Sprint 8)
|
||||
- Settings ⚙ oculto para roles cliente + guard beforeLoad en /settings (Sprint 8)
|
||||
- Interface `Area` en domain/types.ts (Sprint 8)
|
||||
- Campos `areaId`/`areaName` en `Process` (Sprint 8)
|
||||
- Repositorio `supabaseAreaRepo` con CRUD completo para tabla `areas` (Sprint 8)
|
||||
- `fromRow`/`toRow`/`updateEditable`/`getById` en process-repo extendidos con `area_id` (Sprint 8)
|
||||
|
||||
## [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
|
||||
|
||||
75
CLAUDE.md
75
CLAUDE.md
@@ -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
|
||||
|
||||
Antes de aplicar un cambio fuera del scope del prompt actual, evaluar en orden:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# OBSERVATIONS — Sprint 3
|
||||
# OBSERVATIONS — Sprint 8
|
||||
|
||||
> 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.
|
||||
>
|
||||
> **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
|
||||
|
||||
*(vacío — sprint recién abierto)*
|
||||
*(vacío)*
|
||||
|
||||
---
|
||||
|
||||
## Hallazgos resueltos durante el sprint
|
||||
|
||||
*(vacío)*
|
||||
*(vacío — sprint recién abierto)*
|
||||
|
||||
---
|
||||
|
||||
@@ -44,12 +44,44 @@
|
||||
|
||||
## Cierre del sprint (checklist)
|
||||
|
||||
Cuando el sprint cierre, ejecutar:
|
||||
|
||||
```
|
||||
[ ] 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-BACKLOG] a BACKLOG.md (si no están ya)
|
||||
[ ] Mover items [DEFERRED-TECH-DEBT] a TECH_DEBT.md
|
||||
[ ] Mover items [DEFERRED-BACKLOG] a BACKLOG.md
|
||||
[ ] Archivar o purgar este archivo
|
||||
[ ] 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.
|
||||
|
||||
@@ -259,3 +259,245 @@ Sprint 2 tuvo 4 etapas de web app (1, 2, 3, 4), 2 de PDF (5, 6) y 0 de infraestr
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
87
TECH_DEBT.md
87
TECH_DEBT.md
@@ -47,6 +47,93 @@ en los 3 PDFs. Si hay truncamiento, evaluar:
|
||||
|
||||
## 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)
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
|
||||
## Estado actual del proyecto
|
||||
|
||||
**Sprint:** 4 (planificado — BRIEF listo en `sprints/sprint-4/BRIEF.md`)
|
||||
**Sprint:** 5 (en curso — BRIEF listo en `sprints/sprint-5/BRIEF.md`)
|
||||
**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 con clientes en curso
|
||||
|
||||
**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`.
|
||||
@@ -22,19 +24,22 @@
|
||||
|
||||
---
|
||||
|
||||
## Sprint 4 — dirección estratégica
|
||||
## Sprint 5 — dirección estratégica
|
||||
|
||||
Refactorización arquitectural completa. Sin features de producto nuevas.
|
||||
UX polish + quick wins. Retorno al rebalanceo normal (60-70% web). Sin features de gran escala.
|
||||
|
||||
- **Auth:** Supabase + Google Workspace OAuth (`@inquality.com.py`). Abstracción `AuthService` para migración futura a Keycloak.
|
||||
- **Base de datos:** reemplazo de IndexedDB/Dexie por PostgreSQL via Supabase. Datos existentes abandonados (eran solo pruebas).
|
||||
- **Recursos globales:** catálogo compartido del equipo, sin `processId`. Rompe modelo actual — requiere mockup aprobado antes de Etapa 7.
|
||||
- **Privacidad:** procesos con visibilidad `private | team`. Recursos, grupos y tags siempre compartidos. Simulaciones heredan del proceso.
|
||||
- **Sin auditoría:** diferida a sprint dedicado.
|
||||
- **Online-only:** aceptado. Offline en roadmap futuro.
|
||||
- **Keycloak:** diferido. InQuality tiene VPS desa + prod. Keycloak será el auth server de la plataforma InQuality (multi-producto).
|
||||
- **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-4/BRIEF.md` para scope completo.
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -77,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`)
|
||||
- Modelo de escenarios: Modelo A (un BPMN, dos pares de campos por actividad)
|
||||
- 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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
125
docs/CHECKLIST_ENTREGA.md
Normal file
125
docs/CHECKLIST_ENTREGA.md
Normal 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).
|
||||
680
package-lock.json
generated
680
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@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-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
@@ -1961,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": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||
@@ -2065,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": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "process-cost-platform",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
|
||||
@@ -2,6 +2,8 @@ import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
testIgnore: '**/setup/**',
|
||||
globalSetup: './tests/e2e/setup/auth-setup.ts',
|
||||
timeout: 180_000,
|
||||
use: {
|
||||
baseURL: 'http://localhost:4173',
|
||||
|
||||
202
sprints/sprint-4/ETAPA_6_PROMPT.md
Normal file
202
sprints/sprint-4/ETAPA_6_PROMPT.md
Normal 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 1–5B 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.
|
||||
202
sprints/sprint-4/ETAPA_7B_PROMPT.md
Normal file
202
sprints/sprint-4/ETAPA_7B_PROMPT.md
Normal 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
|
||||
202
sprints/sprint-4/ETAPA_7_PROMPT.md
Normal file
202
sprints/sprint-4/ETAPA_7_PROMPT.md
Normal 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 1–6 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.
|
||||
318
sprints/sprint-4/ETAPA_8_PROMPT.md
Normal file
318
sprints/sprint-4/ETAPA_8_PROMPT.md
Normal 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 1–7B 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 1–4)
|
||||
- [ ] `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
249
sprints/sprint-5/BRIEF.md
Normal 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
|
||||
192
sprints/sprint-5/ETAPA_1_PROMPT.md
Normal file
192
sprints/sprint-5/ETAPA_1_PROMPT.md
Normal 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
|
||||
156
sprints/sprint-5/ETAPA_2_PROMPT.md
Normal file
156
sprints/sprint-5/ETAPA_2_PROMPT.md
Normal 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
|
||||
138
sprints/sprint-5/ETAPA_3B_PROMPT.md
Normal file
138
sprints/sprint-5/ETAPA_3B_PROMPT.md
Normal 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
|
||||
250
sprints/sprint-5/ETAPA_3_PROMPT.md
Normal file
250
sprints/sprint-5/ETAPA_3_PROMPT.md
Normal 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
|
||||
105
sprints/sprint-5/ETAPA_4B_PROMPT.md
Normal file
105
sprints/sprint-5/ETAPA_4B_PROMPT.md
Normal 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
|
||||
104
sprints/sprint-5/ETAPA_4C_PROMPT.md
Normal file
104
sprints/sprint-5/ETAPA_4C_PROMPT.md
Normal 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 1–3B 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
|
||||
189
sprints/sprint-5/ETAPA_4D_PROMPT.md
Normal file
189
sprints/sprint-5/ETAPA_4D_PROMPT.md
Normal 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
|
||||
227
sprints/sprint-5/ETAPA_4E_PROMPT.md
Normal file
227
sprints/sprint-5/ETAPA_4E_PROMPT.md
Normal 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
|
||||
224
sprints/sprint-5/ETAPA_4F_PROMPT.md
Normal file
224
sprints/sprint-5/ETAPA_4F_PROMPT.md
Normal 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)
|
||||
273
sprints/sprint-5/ETAPA_4_PROMPT.md
Normal file
273
sprints/sprint-5/ETAPA_4_PROMPT.md
Normal 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
|
||||
229
sprints/sprint-5/ETAPA_5_PROMPT.md
Normal file
229
sprints/sprint-5/ETAPA_5_PROMPT.md
Normal 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.
|
||||
306
sprints/sprint-5/ETAPA_6_PROMPT.md
Normal file
306
sprints/sprint-5/ETAPA_6_PROMPT.md
Normal 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.
|
||||
86
sprints/sprint-5/INFORME_ETAPA_4_FREEZE_PERFIL.md
Normal file
86
sprints/sprint-5/INFORME_ETAPA_4_FREEZE_PERFIL.md
Normal 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 4B–4F. 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
188
sprints/sprint-6/BRIEF.md
Normal 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
|
||||
156
sprints/sprint-6/ETAPA_1_PROMPT.md
Normal file
156
sprints/sprint-6/ETAPA_1_PROMPT.md
Normal 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.
|
||||
241
sprints/sprint-6/ETAPA_2_PROMPT.md
Normal file
241
sprints/sprint-6/ETAPA_2_PROMPT.md
Normal 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).
|
||||
242
sprints/sprint-6/ETAPA_3_PROMPT.md
Normal file
242
sprints/sprint-6/ETAPA_3_PROMPT.md
Normal 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
|
||||
345
sprints/sprint-6/REFERENCIA_GOOGLE_OAUTH_SUPABASE.md
Normal file
345
sprints/sprint-6/REFERENCIA_GOOGLE_OAUTH_SUPABASE.md
Normal 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
555
sprints/sprint-7/BRIEF.md
Normal 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 (001–013):
|
||||
- `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
|
||||
430
sprints/sprint-7/ETAPA_1_PROMPT.md
Normal file
430
sprints/sprint-7/ETAPA_1_PROMPT.md
Normal 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
|
||||
273
sprints/sprint-7/ETAPA_2_PROMPT.md
Normal file
273
sprints/sprint-7/ETAPA_2_PROMPT.md
Normal 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
|
||||
293
sprints/sprint-7/ETAPA_3_PROMPT.md
Normal file
293
sprints/sprint-7/ETAPA_3_PROMPT.md
Normal 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
|
||||
288
sprints/sprint-7/ETAPA_4_PROMPT.md
Normal file
288
sprints/sprint-7/ETAPA_4_PROMPT.md
Normal 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.
|
||||
197
sprints/sprint-7/ETAPA_5_PROMPT.md
Normal file
197
sprints/sprint-7/ETAPA_5_PROMPT.md
Normal 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
|
||||
169
sprints/sprint-7/ETAPA_6_PROMPT.md
Normal file
169
sprints/sprint-7/ETAPA_6_PROMPT.md
Normal 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`
|
||||
133
sprints/sprint-7/ETAPA_7B_PROMPT.md
Normal file
133
sprints/sprint-7/ETAPA_7B_PROMPT.md
Normal 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)
|
||||
176
sprints/sprint-7/ETAPA_7_PROMPT.md
Normal file
176
sprints/sprint-7/ETAPA_7_PROMPT.md
Normal 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
610
sprints/sprint-8/BRIEF.md
Normal 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 |
|
||||
267
sprints/sprint-8/ETAPA_0_PROMPT.md
Normal file
267
sprints/sprint-8/ETAPA_0_PROMPT.md
Normal 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)
|
||||
205
sprints/sprint-8/ETAPA_1_PROMPT.md
Normal file
205
sprints/sprint-8/ETAPA_1_PROMPT.md
Normal 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)
|
||||
262
sprints/sprint-8/ETAPA_2_PROMPT.md
Normal file
262
sprints/sprint-8/ETAPA_2_PROMPT.md
Normal 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`
|
||||
432
sprints/sprint-8/ETAPA_3_PROMPT.md
Normal file
432
sprints/sprint-8/ETAPA_3_PROMPT.md
Normal 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
|
||||
266
sprints/sprint-8/ETAPA_4_5_PROMPT.md
Normal file
266
sprints/sprint-8/ETAPA_4_5_PROMPT.md
Normal 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
|
||||
622
sprints/sprint-8/ETAPA_4_PROMPT.md
Normal file
622
sprints/sprint-8/ETAPA_4_PROMPT.md
Normal 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
|
||||
343
sprints/sprint-8/ETAPA_5_PROMPT.md
Normal file
343
sprints/sprint-8/ETAPA_5_PROMPT.md
Normal 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 1–4 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
|
||||
@@ -2,12 +2,15 @@ import { RouterProvider } from '@tanstack/react-router'
|
||||
import { router } from './router'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
import { AuthProvider } from '@/auth/AuthContext'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<TooltipProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
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 { supabase } from '@/lib/supabase'
|
||||
import type { AuthUser } from './types'
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null
|
||||
loading: boolean
|
||||
isProfileLoaded: boolean
|
||||
signIn: () => Promise<void>
|
||||
signInWithEmailPassword: (email: string, password: string) => Promise<{ error: string | null }>
|
||||
signOut: () => Promise<void>
|
||||
updateUser: (updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => void
|
||||
pendingPasswordReset: boolean
|
||||
clearPasswordReset: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
@@ -29,7 +35,7 @@ function getUserFromStorage(): AuthUser | null {
|
||||
return {
|
||||
id: u.id as string,
|
||||
email,
|
||||
name: (u.user_metadata?.full_name 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',
|
||||
}
|
||||
@@ -59,27 +65,126 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
// 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(() => {
|
||||
// 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 prevId = lastSyncedUserIdRef.current
|
||||
lastSyncedUserIdRef.current = u?.id ?? null
|
||||
setUser(u)
|
||||
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()
|
||||
}, [])
|
||||
|
||||
// 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 signInWithEmailPassword = useCallback(
|
||||
(email: string, password: string) => authService.signInWithEmailPassword(email, password),
|
||||
[]
|
||||
)
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await authService.signOut()
|
||||
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 (
|
||||
<AuthContext.Provider value={{ user, loading, signIn, signOut }}>
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
loading,
|
||||
isProfileLoaded,
|
||||
signIn,
|
||||
signInWithEmailPassword,
|
||||
signOut,
|
||||
updateUser,
|
||||
pendingPasswordReset,
|
||||
clearPasswordReset,
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@@ -5,16 +5,30 @@ export class SupabaseAuthService implements AuthService {
|
||||
async signInWithGoogle(): Promise<void> {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
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
|
||||
}
|
||||
|
||||
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> {
|
||||
const { error } = await supabase.auth.signOut()
|
||||
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> {
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
if (!session?.user) return null
|
||||
@@ -23,44 +37,28 @@ export class SupabaseAuthService implements AuthService {
|
||||
|
||||
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
async (event, session) => {
|
||||
(event, session) => { // NO async — el lock de GoTrueClient está activo durante este callback
|
||||
try {
|
||||
// Token refresh silencioso — no disparar re-sync ni setLoading
|
||||
if (event === 'TOKEN_REFRESHED' || event === 'USER_UPDATED') return
|
||||
if (!session?.user) {
|
||||
// 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)
|
||||
}
|
||||
if (event !== 'INITIAL_SESSION') callback(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (event === 'SIGNED_IN') {
|
||||
// Primer login: upsert del usuario en la tabla users
|
||||
const email = session.user.email ?? ''
|
||||
const role = email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest'
|
||||
const { error: upsertError } = await supabase.from('users').upsert(
|
||||
{
|
||||
id: session.user.id,
|
||||
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)
|
||||
}
|
||||
|
||||
// buildAuthUser es sincrónico — sin DB query, usa metadatos de la sesión
|
||||
const user = this.buildAuthUser(session.user)
|
||||
callback(user)
|
||||
// PASSWORD_RECOVERY tiene sesión activa — incluir para que AuthContext establezca el usuario
|
||||
// antes de redirigir a /reset-password. La señal de redirect se maneja en AuthContext.
|
||||
if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN' && event !== 'PASSWORD_RECOVERY') return
|
||||
// Llamar callback inmediatamente con datos básicos del token OAuth.
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
if (session?.user) callback(this.buildAuthUser(session.user))
|
||||
else if (event !== 'INITIAL_SESSION') callback(null)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -68,14 +66,47 @@ export class SupabaseAuthService implements AuthService {
|
||||
return () => subscription.unsubscribe()
|
||||
}
|
||||
|
||||
// Leer platform_role, org_id, company y name desde la tabla users.
|
||||
// Sobrescribe la heurística de email de buildAuthUser con el valor real de DB.
|
||||
// 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 syncProfileFromDB(userId: string, signal?: AbortSignal): Promise<Pick<AuthUser, 'name' | 'company' | 'platformRole' | 'orgId'> | null> {
|
||||
const base = supabase.from('users').select('name, company, platform_role, org_id').eq('id', userId)
|
||||
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 (misma lógica que el upsert en SIGNED_IN).
|
||||
// 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 {
|
||||
id: supabaseUser.id,
|
||||
email,
|
||||
name: supabaseUser.user_metadata?.full_name as string ?? 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',
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ export interface AuthUser {
|
||||
email: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
platformRole: 'platform_admin' | 'member' | 'guest'
|
||||
platformRole: 'platform_admin' | 'member' | 'client_editor' | 'client_viewer' | 'guest'
|
||||
company?: string
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
signInWithGoogle(): Promise<void>
|
||||
signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }>
|
||||
signOut(): Promise<void>
|
||||
getCurrentUser(): Promise<AuthUser | null>
|
||||
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 · v0.1.0 · Hecho desde Paraguay 🇵🇾
|
||||
InQ ROI · Powered by InQuality · v{APP_VERSION} · Hecho desde Paraguay 🇵🇾
|
||||
</p>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,49 @@
|
||||
import { useState } from 'react'
|
||||
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'
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="h-7 w-7 rounded-full flex items-center justify-center text-[11px] font-medium text-white border border-white/40"
|
||||
style={{ background: '#F59845' }}
|
||||
>
|
||||
{initials(name)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppHeader() {
|
||||
const { user, signOut } = useAuth()
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)',
|
||||
@@ -21,6 +63,7 @@ export function AppHeader() {
|
||||
style={{ height: 24, objectFit: 'contain', cursor: 'pointer' }}
|
||||
/>
|
||||
</Link>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Link
|
||||
to="/"
|
||||
style={{
|
||||
@@ -34,6 +77,52 @@ export function AppHeader() {
|
||||
>
|
||||
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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
30
src/components/ErrorBoundary.tsx
Normal file
30
src/components/ErrorBoundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
145
src/components/ProfileSheet.tsx
Normal file
145
src/components/ProfileSheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
src/components/RequireRole.tsx
Normal file
30
src/components/RequireRole.tsx
Normal 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}</>
|
||||
}
|
||||
76
src/components/ui/dropdown-menu.tsx
Normal file
76
src/components/ui/dropdown-menu.tsx
Normal 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
134
src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal 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 }
|
||||
@@ -54,6 +54,7 @@ export const ProcessSchema = z.object({
|
||||
automationInvestment: z.number().min(0).default(0),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
areaId: z.string().uuid().nullable(),
|
||||
})
|
||||
|
||||
export type ResourceInput = z.infer<typeof ResourceSchema>
|
||||
|
||||
@@ -19,6 +19,11 @@ export interface Process {
|
||||
groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar")
|
||||
tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda
|
||||
ownerId: string // 🆕 Sprint 4 — owner del proceso (auth.uid() al crear)
|
||||
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'
|
||||
@@ -64,7 +69,6 @@ export type ResourceType = 'role' | 'person' | 'system' | 'equipment' | 'supply'
|
||||
|
||||
export interface Resource {
|
||||
id: UUID
|
||||
processId?: UUID // opcional — recursos son catálogo global desde Etapa 3
|
||||
name: string
|
||||
type: ResourceType
|
||||
costPerHour: number
|
||||
@@ -87,6 +91,14 @@ export interface ProcessGroup {
|
||||
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 {
|
||||
id: 'singleton' // siempre un único registro por instalación
|
||||
groupLabel: string // etiqueta configurable para el nivel de agrupación. Default: 'Cliente'
|
||||
|
||||
51
src/features/admin/AdminLayout.tsx
Normal file
51
src/features/admin/AdminLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
523
src/features/admin/OrganizationDetailPage.tsx
Normal file
523
src/features/admin/OrganizationDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
213
src/features/admin/OrganizationsPage.tsx
Normal file
213
src/features/admin/OrganizationsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
src/features/admin/RoleBadge.tsx
Normal file
38
src/features/admin/RoleBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
632
src/features/admin/UsersPage.tsx
Normal file
632
src/features/admin/UsersPage.tsx
Normal 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">
|
||||
Tú
|
||||
</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>
|
||||
)
|
||||
}
|
||||
112
src/features/auth/ConfirmInvitePage.tsx
Normal file
112
src/features/auth/ConfirmInvitePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
src/features/auth/ResetPasswordPage.tsx
Normal file
103
src/features/auth/ResetPasswordPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { AppFooter } from '@/components/AppFooter'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
import { RequireRole } from '@/components/RequireRole'
|
||||
|
||||
const SAMPLE_PROCESSES = [
|
||||
{
|
||||
@@ -81,6 +82,9 @@ export function ImportPage() {
|
||||
groupId: targetGroupId,
|
||||
tags: [],
|
||||
ownerId: user!.id,
|
||||
updatedBy: user!.id,
|
||||
orgId: user!.orgId ?? null,
|
||||
areaId: null,
|
||||
}
|
||||
|
||||
const activityElements = extractActivityElements(graph)
|
||||
@@ -155,7 +159,7 @@ export function ImportPage() {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
}, [navigate, toast])
|
||||
}, [navigate, toast, user])
|
||||
|
||||
const onDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -190,6 +194,7 @@ export function ImportPage() {
|
||||
}
|
||||
|
||||
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">
|
||||
<AppHeader />
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6">
|
||||
@@ -197,7 +202,7 @@ export function ImportPage() {
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center gap-2 text-primary text-sm font-medium mb-4 bg-primary/10 px-3 py-1 rounded-full">
|
||||
<FlaskConical className="h-3.5 w-3.5" />
|
||||
Process Cost Platform · MVP Fase 0
|
||||
InQ ROI
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-3">
|
||||
Cuantificá el costo operativo<br />de tus procesos
|
||||
@@ -261,8 +266,8 @@ export function ImportPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Procesos de ejemplo — visibles directamente */}
|
||||
<div className="mt-8 w-full max-w-lg">
|
||||
{/* Procesos de ejemplo — solo para InQuality */}
|
||||
{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">
|
||||
O cargá un proceso de ejemplo con un click
|
||||
</p>
|
||||
@@ -296,14 +301,11 @@ export function ImportPage() {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-xs text-slate-300">
|
||||
100% en tu navegador · Sin backend · Sin registro · Datos guardados localmente
|
||||
</p>
|
||||
</div>}
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
</RequireRole>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||
import {
|
||||
Upload, Search, Plus, Building2, FolderX, GitBranch,
|
||||
Upload, Search, Building2, GitBranch,
|
||||
Clock, TrendingUp, BarChart2, FileX2, X, Settings,
|
||||
MoreVertical, Trash2, Share2, Lock, User,
|
||||
ChevronDown, FolderOpen,
|
||||
} from 'lucide-react'
|
||||
import { useLibraryStore } from '@/store/library-store'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import type { Process, ProcessGroup, Simulation } from '@/domain/types'
|
||||
import { 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 { AppFooter } from '@/components/AppFooter'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -123,18 +125,21 @@ function KpiBadge({ process, simulation }: { process: Process; simulation: Simul
|
||||
|
||||
// ─── Process Card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, onShared }: {
|
||||
process: Process
|
||||
function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, currentUserName, onShared }: {
|
||||
process: ProcessWithUserNames
|
||||
simulation: Simulation | null | undefined
|
||||
showGroupChip?: boolean
|
||||
groupName?: string
|
||||
onDelete?: () => void
|
||||
currentUserId: string
|
||||
currentUserName: string
|
||||
onShared?: () => void
|
||||
}) {
|
||||
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(() => {
|
||||
@@ -164,9 +169,11 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete,
|
||||
<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">
|
||||
<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" />
|
||||
<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>
|
||||
{(process.tags.length > 0 || showGroupChip) && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
@@ -261,127 +268,22 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Group Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function GroupCard({ group, processCount, lastUpdated, onClick }: {
|
||||
group: ProcessGroup
|
||||
processCount: number
|
||||
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
|
||||
onClick={confirm}
|
||||
className="text-[11px] px-2 py-0.5 rounded font-medium text-white"
|
||||
style={{ background: '#F59845' }}
|
||||
>
|
||||
Crear
|
||||
</button>
|
||||
<button
|
||||
onClick={cancel}
|
||||
className="text-[11px] px-2 py-0.5 rounded font-medium text-muted-foreground border border-border"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function HomeView({
|
||||
onNavigateToGroup,
|
||||
onImport,
|
||||
ownerFilter,
|
||||
currentUserId,
|
||||
currentUserName,
|
||||
onShared,
|
||||
}: {
|
||||
onNavigateToGroup: (groupId: string | null) => void
|
||||
onImport: (groupId?: string) => void
|
||||
onImport: () => void
|
||||
ownerFilter: 'all' | 'mine'
|
||||
currentUserId: string
|
||||
currentUserName: string
|
||||
onShared: () => void
|
||||
}) {
|
||||
const { groups, processes, settings, createGroup, isLoading } = useLibraryStore()
|
||||
const { groups, processes, isLoading, error, load } = useLibraryStore()
|
||||
const { user } = useAuth()
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeTags, setActiveTags] = useState<string[]>([])
|
||||
@@ -391,10 +293,46 @@ function HomeView({
|
||||
: processes
|
||||
|
||||
const cloud = tagCloudData(visibleProcesses)
|
||||
const unclassifiedProcesses = visibleProcesses.filter((p) => p.groupId == null)
|
||||
|
||||
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
|
||||
? visibleProcesses.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
|
||||
: []
|
||||
@@ -422,8 +360,56 @@ function HomeView({
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (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 (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||
<Building2 className="h-16 w-16 text-muted-foreground/30" />
|
||||
@@ -485,25 +471,23 @@ function HomeView({
|
||||
{filteredProcesses.length === 0 && (
|
||||
<p className="text-[13px] text-muted-foreground py-4 text-center">Sin resultados para esa búsqueda.</p>
|
||||
)}
|
||||
{filteredProcesses.map((p) => {
|
||||
const group = groups.find((g) => g.id === p.groupId)
|
||||
return (
|
||||
{filteredProcesses.map((p) => (
|
||||
<ProcessCardWithSim
|
||||
key={p.id}
|
||||
process={p}
|
||||
showGroupChip
|
||||
groupName={group?.name}
|
||||
groupName={p.areaName ?? p.orgName ?? undefined}
|
||||
currentUserId={currentUserId}
|
||||
currentUserName={currentUserName}
|
||||
onShared={onShared}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vista normal — grupos */}
|
||||
{!isSearching && (
|
||||
<>
|
||||
{/* Vista normal — org → área (solo roles internos) */}
|
||||
{!isSearching && !isClientRole && (
|
||||
<div className="space-y-3" data-testid="groups-header">
|
||||
{/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */}
|
||||
{cloud.length >= 3 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -537,58 +521,104 @@ function HomeView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header sección grupos */}
|
||||
{/* Grupos org → área */}
|
||||
{orgGroups.map(({ orgId, orgName, processes: orgProcesses }) => {
|
||||
const isCollapsed = collapsedOrgs.has(orgId)
|
||||
const areaGroups = getAreaGroups(orgProcesses)
|
||||
|
||||
return (
|
||||
<div key={orgId} id={`org-section-${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">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{settings.groupLabel}s
|
||||
</span>
|
||||
<span className="text-[10px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground">
|
||||
{groups.length}
|
||||
<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>
|
||||
|
||||
{/* Grid de grupos */}
|
||||
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
|
||||
{groups.map((group) => {
|
||||
const groupProcesses = visibleProcesses.filter((p) => p.groupId === group.id)
|
||||
const lastUpdated = groupProcesses.length > 0
|
||||
? Math.max(...groupProcesses.map((p) => p.updatedAt))
|
||||
: null
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
processCount={groupProcesses.length}
|
||||
lastUpdated={lastUpdated}
|
||||
onClick={() => onNavigateToGroup(group.id)}
|
||||
<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: visible si hay >1 área o si el área tiene nombre */}
|
||||
{(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>
|
||||
)
|
||||
})}
|
||||
<NewGroupCard
|
||||
label={settings.groupLabel}
|
||||
onCreate={(name) => createGroup(name, user!.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sin clasificar */}
|
||||
{unclassifiedProcesses.length > 0 && (
|
||||
{/* 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
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all hover:border-border/80"
|
||||
style={{ border: '0.5px dashed hsl(var(--border))', background: 'hsl(var(--card))', borderRadius: '0.5rem' }}
|
||||
onClick={() => onNavigateToGroup(null)}
|
||||
onClick={() => onImport()}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: '#F59845' }}
|
||||
>
|
||||
<FolderX className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[13px] font-medium text-secondary-foreground">
|
||||
Procesos sin {settings.groupLabel.toLowerCase()} asignado
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground flex-shrink-0">
|
||||
{unclassifiedProcesses.length} proceso{unclassifiedProcesses.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<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>
|
||||
)
|
||||
@@ -596,11 +626,12 @@ function HomeView({
|
||||
|
||||
// ─── ProcessCardWithSim (carga simulación lazy) ───────────────────────────────
|
||||
|
||||
function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, onShared }: {
|
||||
process: Process
|
||||
function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, currentUserName, onShared }: {
|
||||
process: ProcessWithUserNames
|
||||
showGroupChip?: boolean
|
||||
groupName?: string
|
||||
currentUserId: string
|
||||
currentUserName: string
|
||||
onShared: () => void
|
||||
}) {
|
||||
const { getLatestSimulation, deleteProcess } = useLibraryStore()
|
||||
@@ -622,6 +653,7 @@ function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId,
|
||||
groupName={groupName}
|
||||
onDelete={() => deleteProcess(process.id)}
|
||||
currentUserId={currentUserId}
|
||||
currentUserName={currentUserName}
|
||||
onShared={onShared}
|
||||
/>
|
||||
)
|
||||
@@ -633,12 +665,14 @@ function GroupView({
|
||||
groupId,
|
||||
onBack,
|
||||
currentUserId,
|
||||
currentUserName,
|
||||
ownerFilter,
|
||||
onShared,
|
||||
}: {
|
||||
groupId: string | null
|
||||
onBack: () => void
|
||||
currentUserId: string
|
||||
currentUserName: string
|
||||
ownerFilter: 'all' | 'mine'
|
||||
onShared: () => void
|
||||
}) {
|
||||
@@ -703,6 +737,7 @@ function GroupView({
|
||||
key={p.id}
|
||||
process={p}
|
||||
currentUserId={currentUserId}
|
||||
currentUserName={currentUserName}
|
||||
onShared={onShared}
|
||||
/>
|
||||
))}
|
||||
@@ -715,27 +750,33 @@ function GroupView({
|
||||
|
||||
export function LibraryPage() {
|
||||
const navigate = useNavigate()
|
||||
const { load, processes, groups, settings } = useLibraryStore()
|
||||
const { user } = useAuth()
|
||||
const { load, processes, isLoading } = useLibraryStore()
|
||||
const { user, isProfileLoaded } = useAuth()
|
||||
const [view, setView] = useState<'home' | 'group'>('home')
|
||||
const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
|
||||
const [transitioning, setTransitioning] = useState(false)
|
||||
const [ownerFilter, setOwnerFilter] = useState<'all' | 'mine'>('all')
|
||||
const { message: toastMessage, show: showToast } = useToast()
|
||||
|
||||
const location = useLocation()
|
||||
const orgParam = useMemo(
|
||||
() => new URLSearchParams(location.search).get('org') ?? undefined,
|
||||
[location.search]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return // no fetchear hasta tener usuario confirmado (evita race condition con sesión Supabase)
|
||||
load()
|
||||
}, [load, user])
|
||||
}, [load, user?.id])
|
||||
|
||||
function navigateToGroup(groupId: string | null) {
|
||||
setTransitioning(true)
|
||||
setTimeout(() => {
|
||||
setActiveGroupId(groupId)
|
||||
setView('group')
|
||||
setTransitioning(false)
|
||||
}, 150)
|
||||
}
|
||||
// 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])
|
||||
|
||||
function navigateHome() {
|
||||
setTransitioning(true)
|
||||
@@ -746,16 +787,35 @@ export function LibraryPage() {
|
||||
}, 150)
|
||||
}
|
||||
|
||||
function handleImport(groupId?: string) {
|
||||
if (groupId) {
|
||||
navigate({ to: '/import', search: { groupId } })
|
||||
} else {
|
||||
function handleImport() {
|
||||
navigate({ to: '/import' })
|
||||
}
|
||||
|
||||
// Esperar a que syncProfileFromDB complete antes de renderizar UI rol-dependiente.
|
||||
// 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 totalGroups = groups.length
|
||||
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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
@@ -766,7 +826,10 @@ export function LibraryPage() {
|
||||
<div>
|
||||
<h1 className="text-[18px] font-medium">Biblioteca de procesos</h1>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -794,6 +857,7 @@ export function LibraryPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isClientRole && (
|
||||
<button
|
||||
onClick={() => navigate({ to: '/settings' })}
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||
@@ -801,14 +865,17 @@ export function LibraryPage() {
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{user.platformRole !== 'client_viewer' && (
|
||||
<button
|
||||
onClick={() => handleImport(view === 'group' && activeGroupId ? activeGroupId : undefined)}
|
||||
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>
|
||||
|
||||
@@ -819,10 +886,10 @@ export function LibraryPage() {
|
||||
>
|
||||
{view === 'home' ? (
|
||||
<HomeView
|
||||
onNavigateToGroup={navigateToGroup}
|
||||
onImport={handleImport}
|
||||
ownerFilter={ownerFilter}
|
||||
currentUserId={currentUserId}
|
||||
currentUserName={currentUserName}
|
||||
onShared={() => showToast('Proceso compartido con el equipo')}
|
||||
/>
|
||||
) : (
|
||||
@@ -830,11 +897,13 @@ export function LibraryPage() {
|
||||
groupId={activeGroupId}
|
||||
onBack={navigateHome}
|
||||
currentUserId={currentUserId}
|
||||
currentUserName={currentUserName}
|
||||
ownerFilter={ownerFilter}
|
||||
onShared={() => showToast('Proceso compartido con el equipo')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<AppFooter />
|
||||
</div>
|
||||
|
||||
<Toast message={toastMessage} />
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
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() {
|
||||
return (
|
||||
@@ -12,12 +22,39 @@ function GoogleIcon() {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<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>{`
|
||||
@@ -40,16 +77,63 @@ export function LoginPage() {
|
||||
|
||||
<button
|
||||
onClick={signIn}
|
||||
disabled={loading}
|
||||
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"
|
||||
disabled={loading || isSubmitting}
|
||||
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' }}
|
||||
>
|
||||
<GoogleIcon />
|
||||
Iniciar sesión con Google
|
||||
Continuar con Google
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-slate-400">
|
||||
Solo para usuarios con cuenta @inquality.com.py
|
||||
<div className="w-full flex items-center gap-3">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Bot, ArrowUpDown } from 'lucide-react'
|
||||
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 type { ActivitySimResult } from '@/domain/types'
|
||||
|
||||
@@ -12,8 +13,12 @@ interface ComparisonRow {
|
||||
savings: number
|
||||
savingsPercent: number
|
||||
automatable: boolean
|
||||
expectedSavings: number | null
|
||||
efficiency: number | null
|
||||
}
|
||||
|
||||
type SortBy = 'savings' | 'expectedSavings' | 'efficiency'
|
||||
|
||||
interface ComparisonTableProps {
|
||||
perActivityActual: ActivitySimResult[]
|
||||
perActivityAutomated: ActivitySimResult[]
|
||||
@@ -21,12 +26,46 @@ interface ComparisonTableProps {
|
||||
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({
|
||||
perActivityActual,
|
||||
perActivityAutomated,
|
||||
automatableIds,
|
||||
currency,
|
||||
}: ComparisonTableProps) {
|
||||
const [sortBy, setSortBy] = useState<SortBy>('efficiency')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||
|
||||
const automatedByActivityId = useMemo(() => {
|
||||
@@ -36,13 +75,22 @@ export function ComparisonTable({
|
||||
}, [perActivityAutomated])
|
||||
|
||||
const rows: ComparisonRow[] = useMemo(() => {
|
||||
return perActivityActual
|
||||
.map((act) => {
|
||||
const built = perActivityActual.map((act) => {
|
||||
const automated = automatedByActivityId.get(act.activityId)
|
||||
const automatedCost = automated?.expectedTotalCost ?? act.expectedTotalCost
|
||||
const savings = act.expectedTotalCost - automatedCost
|
||||
const savingsPercent =
|
||||
act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
|
||||
const automatable = automatableIds.has(act.bpmnElementId)
|
||||
|
||||
const expectedSavings =
|
||||
automatable && savings > 0 ? savings * act.executionProbability : null
|
||||
|
||||
const efficiency =
|
||||
automatable && savings > 0 && act.executionTimeMinutes > 0
|
||||
? (savings * act.executionProbability) / act.executionTimeMinutes
|
||||
: null
|
||||
|
||||
return {
|
||||
activityId: act.activityId,
|
||||
activityName: act.activityName,
|
||||
@@ -50,11 +98,30 @@ export function ComparisonTable({
|
||||
automatedCost,
|
||||
savings,
|
||||
savingsPercent,
|
||||
automatable: automatableIds.has(act.bpmnElementId),
|
||||
automatable,
|
||||
expectedSavings,
|
||||
efficiency,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => (b.savings - a.savings) * (sortDir === 'desc' ? 1 : -1))
|
||||
}, [perActivityActual, automatedByActivityId, automatableIds, sortDir])
|
||||
|
||||
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) {
|
||||
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 (
|
||||
<div className="rounded-xl border border-inq-orange/20 overflow-hidden">
|
||||
<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 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">
|
||||
Ahorro
|
||||
<ArrowUpDown className="h-3 w-3 opacity-40" />
|
||||
<ArrowUpDown className={`h-3 w-3 ${sortBy === 'savings' ? 'opacity-80' : 'opacity-40'}`} />
|
||||
</span>
|
||||
</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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -124,6 +235,16 @@ export function ComparisonTable({
|
||||
? (row.savings > 0 ? '+' : '') + formatPercent(row.savingsPercent)
|
||||
: '—'}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -4,10 +4,11 @@ import { formatCurrency } from '@/lib/format'
|
||||
import type { RoiResult } from '@/domain/roi'
|
||||
|
||||
interface CumulativeSavingsChartProps {
|
||||
roi: RoiResult
|
||||
roi: RoiResult // caso ajustado (o base si no hay ajuste)
|
||||
horizonYears: number
|
||||
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({
|
||||
@@ -15,6 +16,7 @@ export function CumulativeSavingsChart({
|
||||
horizonYears,
|
||||
currency,
|
||||
investment,
|
||||
roiBase,
|
||||
}: CumulativeSavingsChartProps) {
|
||||
const option = useMemo(() => {
|
||||
const years = Array.from({ length: horizonYears + 1 }, (_, i) => i)
|
||||
@@ -45,53 +47,74 @@ export function CumulativeSavingsChart({
|
||||
lineStyle: { color: '#94a3b8', type: 'solid', width: 1 },
|
||||
})
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: unknown[]) => {
|
||||
const p = params[0] as { dataIndex: number; value: [number, number] }
|
||||
const year = p.value[0]
|
||||
const val = p.value[1]
|
||||
return `<div style="font-size:12px;line-height:1.6">
|
||||
<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>`
|
||||
},
|
||||
},
|
||||
grid: { left: 16, right: 24, top: 16, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
name: 'Año',
|
||||
nameLocation: 'end',
|
||||
nameTextStyle: { fontSize: 10, color: '#94a3b8' },
|
||||
min: 0,
|
||||
max: horizonYears,
|
||||
interval: 1,
|
||||
axisLabel: { fontSize: 11, formatter: (v: number) => `Año ${v}` },
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (v: number) => {
|
||||
if (v === 0) return '0'
|
||||
const abs = Math.abs(v)
|
||||
const sign = v < 0 ? '-' : ''
|
||||
if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`
|
||||
if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(0)}k`
|
||||
return String(v)
|
||||
},
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
// 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: {
|
||||
areaStyle: baseValues
|
||||
? undefined
|
||||
: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 0, y2: 1,
|
||||
@@ -108,10 +131,61 @@ export function CumulativeSavingsChart({
|
||||
symbol: ['none', 'none'],
|
||||
data: markLineData,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: unknown[]) => {
|
||||
const typedParams = params as Array<{ seriesName: string; value: [number, number] }>
|
||||
const adjustedP = typedParams.find((p) => p.seriesName === 'Ajustado')
|
||||
const baseP = typedParams.find((p) => p.seriesName === 'Base')
|
||||
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>Acumulado ajustado: <strong style="color:${val >= 0 ? '#16A34A' : '#EF4444'}">${formatCurrency(val, currency)}</strong></div>`
|
||||
if (baseP) {
|
||||
const baseVal = baseP.value[1]
|
||||
html += `<div>Acumulado base: <strong style="color:${baseVal >= 0 ? '#16A34A' : '#EF4444'}">${formatCurrency(baseVal, currency)}</strong></div>`
|
||||
}
|
||||
}, [roi, horizonYears, currency, investment])
|
||||
html += '</div>'
|
||||
return html
|
||||
},
|
||||
},
|
||||
grid: { left: 16, right: 24, top: 16, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
name: 'Año',
|
||||
nameLocation: 'end',
|
||||
nameTextStyle: { fontSize: 10, color: '#94a3b8' },
|
||||
min: 0,
|
||||
max: horizonYears,
|
||||
interval: 1,
|
||||
axisLabel: { fontSize: 11, formatter: (v: number) => `Año ${v}` },
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: baseValues ? yMin - yPad : undefined,
|
||||
max: baseValues ? yMax + yPad : undefined,
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (v: number) => {
|
||||
if (v === 0) return '0'
|
||||
const abs = Math.abs(v)
|
||||
const sign = v < 0 ? '-' : ''
|
||||
if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`
|
||||
if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(0)}k`
|
||||
return String(v)
|
||||
},
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
series,
|
||||
}
|
||||
}, [roi, horizonYears, currency, investment, roiBase])
|
||||
|
||||
return <ReactECharts option={option} style={{ height: 280 }} notMerge />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatCurrency, formatPercent } from '@/lib/format'
|
||||
import { heatmapLegendBounds, HEATMAP_LOW_HEX, HEATMAP_MID_HEX, HEATMAP_HIGH_HEX } from '@/lib/colors'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
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 { ActivitySimResult } from '@/domain/types'
|
||||
|
||||
@@ -12,8 +12,10 @@ interface HeatmapLegendProps {
|
||||
export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProps) {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -21,7 +23,7 @@ export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProp
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<span className="text-xs text-slate-500 shrink-0">Bajo costo</span>
|
||||
<div className="flex-1 h-3 rounded-full" style={{
|
||||
background: `linear-gradient(to right, ${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>
|
||||
<div className="h-4 border-l border-slate-200 mx-1" />
|
||||
|
||||
@@ -8,20 +8,20 @@ interface HeatmapModeToggleProps {
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<HeatmapMode, { label: string; tip: string }> = {
|
||||
relative: {
|
||||
label: 'Costo relativo (%)',
|
||||
tip: 'Colorea cada actividad en función de su % del costo total. Útil para ver proporciones.',
|
||||
percentile: {
|
||||
label: 'Percentil',
|
||||
tip: 'Colorea cada actividad según su posición relativa entre todas las actividades del proceso. No se distorsiona por outliers.',
|
||||
},
|
||||
absolute: {
|
||||
label: 'Costo absoluto',
|
||||
tip: 'Colorea según el costo esperado en la moneda del proceso. Útil para comparar magnitudes reales.',
|
||||
minmax: {
|
||||
label: 'Min-max',
|
||||
tip: '(costo − mínimo) / (máximo − mínimo). Refleja valores absolutos, pero se comprime si hay outliers extremos.',
|
||||
},
|
||||
}
|
||||
|
||||
export function HeatmapModeToggle({ mode, onChange }: HeatmapModeToggleProps) {
|
||||
return (
|
||||
<div className="inline-flex rounded-lg border border-slate-200 bg-slate-50 p-0.5 text-xs">
|
||||
{(['relative', 'absolute'] as HeatmapMode[]).map((m) => {
|
||||
{(['percentile', 'minmax'] as HeatmapMode[]).map((m) => {
|
||||
const { label, tip } = MODE_LABELS[m]
|
||||
return (
|
||||
<Tooltip key={m}>
|
||||
|
||||
@@ -22,26 +22,32 @@ import { CostByResourceChart } from './CostByResourceChart'
|
||||
import { ActivitiesTable } from './ActivitiesTable'
|
||||
import { ComparisonTable } from './ComparisonTable'
|
||||
import { CumulativeSavingsChart } from './CumulativeSavingsChart'
|
||||
import { SensitivityPanel } from './SensitivityPanel'
|
||||
import { TopSavingsChart } from './TopSavingsChart'
|
||||
import { TransparencySection } from './TransparencySection'
|
||||
import { AutomationScenarioNote } from './AutomationScenarioNote'
|
||||
import { MethodologyFooter } from './MethodologyFooter'
|
||||
import type { HeatmapMode } from '@/lib/colors'
|
||||
import { useHeatmapMode } from '@/lib/hooks/useHeatmapMode'
|
||||
import { useSimulationStore } from '@/store/simulation-store'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
|
||||
export function ReportPage() {
|
||||
const { processId } = useParams({ from: '/report/$processId' })
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const { process, simulation, activities, resources, isLoading, error } = useReportData(processId)
|
||||
|
||||
const { toast } = useToast()
|
||||
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>('relative')
|
||||
const [heatmapMode, setHeatmapMode] = useHeatmapMode()
|
||||
const { lastPersistedExecutedAt, loadLatestForProcess } = useSimulationStore()
|
||||
|
||||
// Cargar executedAt persistido si el usuario llega directo al reporte (sin pasar por workspace)
|
||||
// Intencional: dep [process?.id] — solo re-ejecutar cuando cambia el proceso, no en cada
|
||||
// re-render por cambios de nombre/cliente/etc. del objeto process.
|
||||
useEffect(() => {
|
||||
if (process) loadLatestForProcess(process.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [process?.id, loadLatestForProcess])
|
||||
|
||||
// Set de bpmnElementIds marcados como automatizables — debe estar antes de los early returns
|
||||
@@ -64,13 +70,31 @@ export function ReportPage() {
|
||||
})
|
||||
}, [simulation, process])
|
||||
|
||||
// Sliders del panel de sensibilidad — efímero, no persiste (Sprint 6 Etapa 2)
|
||||
const [volumeAdjust, setVolumeAdjust] = useState(0) // entero: -50 a +100
|
||||
const [investmentAdjust, setInvestmentAdjust] = useState(0) // entero: -50 a +100
|
||||
|
||||
// ROI ajustado por sliders de sensibilidad — recalcula solo los agregados anuales,
|
||||
// sin tocar costPerExecutionActual ni costPerExecutionAutomated (son estables, no se re-simula).
|
||||
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])
|
||||
|
||||
// Breakdown del escenario actual, indexado por activityId, para comparación lado a lado en tab Automatizado
|
||||
const actualBreakdownById = useMemo(() => {
|
||||
if (!simulation?.result) return undefined
|
||||
return Object.fromEntries(
|
||||
simulation.result.perActivity.map((a) => [a.activityId, a.resourceCostBreakdown])
|
||||
)
|
||||
}, [simulation?.result])
|
||||
}, [simulation])
|
||||
|
||||
// Estado y refs para el tab "Actual"
|
||||
const [selectedIdActual, setSelectedIdActual] = useState<string | null>(null)
|
||||
@@ -86,7 +110,8 @@ export function ReportPage() {
|
||||
|
||||
const [isExportingPdf, setIsExportingPdf] = useState(false)
|
||||
const [isExportingCsv, setIsExportingCsv] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'actual' | 'automatizado' | 'roi'>('actual')
|
||||
const defaultTab: 'actual' | 'automatizado' | 'roi' = user?.platformRole === 'client_viewer' ? 'roi' : 'actual'
|
||||
const [activeTab, setActiveTab] = useState<'actual' | 'automatizado' | 'roi'>(defaultTab)
|
||||
|
||||
// Tab "Actual" — cross-linking heatmap ↔ tabla
|
||||
const handleHeatmapClickActual = useCallback((bpmnElementId: string) => {
|
||||
@@ -272,7 +297,7 @@ export function ReportPage() {
|
||||
/>
|
||||
|
||||
{/* ── Tabs ─────────────────────────────────────────────────────── */}
|
||||
<Tabs defaultValue="actual" className="w-full" onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
|
||||
<Tabs defaultValue={defaultTab} className="w-full" onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="actual">Actual</TabsTrigger>
|
||||
|
||||
@@ -506,37 +531,36 @@ export function ReportPage() {
|
||||
<NeedsResimulate processId={processId} />
|
||||
) : (
|
||||
<>
|
||||
{/* KPIs de ROI */}
|
||||
{/* KPIs de ROI — actualizados por sliders de sensibilidad */}
|
||||
<RoiKpiBar
|
||||
roi={roi}
|
||||
roi={adjustedRoi ?? roi}
|
||||
currency={process.currency}
|
||||
horizonYears={process.analysisHorizonYears}
|
||||
/>
|
||||
|
||||
{/* Tabla de comparación actividad por actividad */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-4">
|
||||
Comparación por actividad
|
||||
</h2>
|
||||
<ComparisonTable
|
||||
perActivityActual={result.perActivity}
|
||||
perActivityAutomated={resultAutomated!.perActivity}
|
||||
automatableIds={automatableIds}
|
||||
currency={process.currency}
|
||||
{/* Panel de sensibilidad — colapsado por defecto */}
|
||||
<SensitivityPanel
|
||||
process={process}
|
||||
volumeAdjust={volumeAdjust}
|
||||
investmentAdjust={investmentAdjust}
|
||||
onVolumeChange={setVolumeAdjust}
|
||||
onInvestmentChange={setInvestmentAdjust}
|
||||
onReset={() => { setVolumeAdjust(0); setInvestmentAdjust(0) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gráficos: ahorro acumulado + top ahorros */}
|
||||
{/* Gráficos: ahorro acumulado + top ahorros — adyacente al SensitivityPanel
|
||||
para que el efecto de los sliders sea visible sin scrollear */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
||||
Ahorro acumulado neto ({process.analysisHorizonYears} años)
|
||||
</h3>
|
||||
<CumulativeSavingsChart
|
||||
roi={roi}
|
||||
roi={adjustedRoi ?? roi}
|
||||
roiBase={volumeAdjust !== 0 || investmentAdjust !== 0 ? roi : undefined}
|
||||
horizonYears={process.analysisHorizonYears}
|
||||
currency={process.currency}
|
||||
investment={process.automationInvestment}
|
||||
investment={process.automationInvestment * (1 + investmentAdjust / 100)}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||
@@ -551,6 +575,19 @@ export function ReportPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabla de comparación actividad por actividad — per-ejecución, no varía con los sliders */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-4">
|
||||
Comparación por actividad
|
||||
</h2>
|
||||
<ComparisonTable
|
||||
perActivityActual={result.perActivity}
|
||||
perActivityAutomated={resultAutomated!.perActivity}
|
||||
automatableIds={automatableIds}
|
||||
currency={process.currency}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Transparencia metodológica (condicional) */}
|
||||
<div className="mb-6">
|
||||
<TransparencySection
|
||||
|
||||
122
src/features/report/SensitivityPanel.tsx
Normal file
122
src/features/report/SensitivityPanel.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, SlidersHorizontal } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatCurrency, formatNumber } from '@/lib/format'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function deltaLabel(adjust: number): string {
|
||||
if (adjust === 0) return '(valor base)'
|
||||
return `(${adjust > 0 ? '+' : ''}${adjust}%)`
|
||||
}
|
||||
|
||||
export function SensitivityPanel({
|
||||
process,
|
||||
volumeAdjust,
|
||||
investmentAdjust,
|
||||
onVolumeChange,
|
||||
onInvestmentChange,
|
||||
onReset,
|
||||
}: SensitivityPanelProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const isActive = volumeAdjust !== 0 || investmentAdjust !== 0
|
||||
|
||||
const adjustedVolume = process.annualFrequency * (1 + volumeAdjust / 100)
|
||||
const adjustedInvestment = process.automationInvestment * (1 + investmentAdjust / 100)
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4 text-amber-700" />
|
||||
<span className="text-sm font-semibold text-amber-900">Análisis de sensibilidad</span>
|
||||
{isActive && (
|
||||
<span className="bg-orange-100 text-orange-700 text-xs font-medium px-2 py-0.5 rounded-full">
|
||||
Análisis activo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-amber-700" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-amber-700" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-4 space-y-5">
|
||||
{/* Volumen anual */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-700">Volumen anual</label>
|
||||
<p className="text-sm font-mono text-slate-800 mt-0.5">
|
||||
{formatNumber(adjustedVolume, 0)} ej/año {deltaLabel(volumeAdjust)}
|
||||
</p>
|
||||
<input
|
||||
type="range"
|
||||
min={-50}
|
||||
max={100}
|
||||
step={10}
|
||||
value={volumeAdjust}
|
||||
onChange={(e) => onVolumeChange(Number(e.target.value))}
|
||||
className="w-full mt-2"
|
||||
style={{ accentColor: '#F59845' }}
|
||||
aria-label="Ajuste de volumen anual"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-slate-400 mt-1">
|
||||
<span>-50%</span>
|
||||
<span>0%</span>
|
||||
<span>+100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inversión en automatización */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-700">Inversión en automatización</label>
|
||||
<p className="text-sm font-mono text-slate-800 mt-0.5">
|
||||
{formatCurrency(adjustedInvestment, process.currency)} {deltaLabel(investmentAdjust)}
|
||||
</p>
|
||||
<input
|
||||
type="range"
|
||||
min={-50}
|
||||
max={100}
|
||||
step={10}
|
||||
value={investmentAdjust}
|
||||
onChange={(e) => onInvestmentChange(Number(e.target.value))}
|
||||
className="w-full mt-2"
|
||||
style={{ accentColor: '#F59845' }}
|
||||
aria-label="Ajuste de inversión en automatización"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-slate-400 mt-1">
|
||||
<span>-50%</span>
|
||||
<span>0%</span>
|
||||
<span>+100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={onReset}>
|
||||
Restablecer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo'
|
||||
import { supabaseResourceRepo } from '@/persistence/supabase/resource-repo'
|
||||
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
||||
@@ -30,8 +29,6 @@ export function useReportData(processId: string): ReportData {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Barrera única: garantiza sesión restaurada antes del fan-out en paralelo
|
||||
await supabase.auth.getSession()
|
||||
const [proc, sim, acts, res] = await Promise.all([
|
||||
supabaseProcessRepo.getById(processId),
|
||||
supabaseSimulationRepo.getLatestByProcess(processId),
|
||||
|
||||
329
src/features/resources/ResourceCatalogPage.tsx
Normal file
329
src/features/resources/ResourceCatalogPage.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useSearch, Link } from '@tanstack/react-router'
|
||||
import { Users2, Search, Plus, Pencil, Trash2, ArrowLeft, CornerUpLeft, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { supabaseResourceRepo, type ResourceWithCreator } from '@/persistence/supabase/resource-repo'
|
||||
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
||||
import { formatCostPerHour } from '@/lib/format'
|
||||
import { RESOURCE_TYPE_LABELS, RESOURCE_TYPE_STYLES } from './resource-types'
|
||||
import { ResourceTypeAvatar } from './resource-display'
|
||||
import { ResourceFormSheet } from './ResourceFormSheet'
|
||||
import type { Resource, ResourceType } from '@/domain/types'
|
||||
|
||||
export function ResourceCatalogPage() {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const search_ = useSearch({ from: '/recursos' })
|
||||
const fromProcess = (search_ as { fromProcess?: string }).fromProcess
|
||||
const [fromProcessName, setFromProcessName] = useState<string | null>(null)
|
||||
const [resources, setResources] = useState<ResourceWithCreator[]>([])
|
||||
const [processCounts, setProcessCounts] = useState<Record<string, number>>({})
|
||||
const [expandedResourceId, setExpandedResourceId] = useState<string | null>(null)
|
||||
const [expandedProcesses, setExpandedProcesses] = useState<Record<string, { id: string; name: string }[]>>({})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<ResourceType | 'all'>('all')
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Resource | undefined>(undefined)
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fromProcess) return
|
||||
supabaseProcessRepo.getById(fromProcess).then((p) => setFromProcessName(p?.name ?? null))
|
||||
}, [fromProcess])
|
||||
|
||||
function backToProcess() {
|
||||
if (fromProcess) navigate({ to: '/workspace/$processId', params: { processId: fromProcess } })
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [data, counts] = await Promise.all([
|
||||
supabaseResourceRepo.getAllWithCreator(),
|
||||
supabaseResourceRepo.getProcessCountsPerResource(),
|
||||
])
|
||||
setResources(data)
|
||||
setProcessCounts(counts)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
async function toggleExpanded(resourceId: string) {
|
||||
if (expandedResourceId === resourceId) {
|
||||
setExpandedResourceId(null)
|
||||
return
|
||||
}
|
||||
setExpandedResourceId(resourceId)
|
||||
if (!expandedProcesses[resourceId]) {
|
||||
const processes = await supabaseResourceRepo.getProcessesForResource(resourceId)
|
||||
setExpandedProcesses((prev) => ({ ...prev, [resourceId]: processes }))
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = resources.filter((r) => {
|
||||
const matchesName = r.name.toLowerCase().includes(search.toLowerCase())
|
||||
const matchesType = typeFilter === 'all' || r.type === typeFilter
|
||||
return matchesName && matchesType
|
||||
})
|
||||
|
||||
function startCreate() {
|
||||
setEditing(undefined)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
function startEdit(resource: Resource) {
|
||||
setEditing(resource)
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave(resource: Resource) {
|
||||
await supabaseResourceRepo.save(resource, user!.id)
|
||||
await load()
|
||||
}
|
||||
|
||||
async function handleDelete(resourceId: string) {
|
||||
setDeleteError(null)
|
||||
try {
|
||||
await supabaseResourceRepo.delete(resourceId)
|
||||
setConfirmingDeleteId(null)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'No se pudo eliminar el recurso')
|
||||
setConfirmingDeleteId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-[18px] font-medium">Catálogo de recursos</h1>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Users2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Compartido con todo el equipo · {resources.length} recurso{resources.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={startCreate} className="gap-2 text-white" style={{ background: '#F59845' }}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nuevo recurso
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fromProcess && (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 mb-4 px-4 py-3 rounded-lg"
|
||||
style={{ background: '#FEF3E7', borderLeft: '3px solid #F59845' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 -ml-1" onClick={backToProcess}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<p className="text-[13px] font-medium">
|
||||
Estás viendo el catálogo desde el proceso {fromProcessName ? `"${fromProcessName}"` : ''}
|
||||
</p>
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Seleccioná un recurso y usalo en una actividad al volver al workspace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="gap-1.5 shrink-0" onClick={backToProcess}>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Volver al proceso
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteError && (
|
||||
<div className="mb-4 px-3 py-2 rounded-lg text-[12px] border border-destructive/30 bg-destructive/10 text-destructive">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar recurso por nombre..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={typeFilter} onValueChange={(v) => setTypeFilter(v as ResourceType | 'all')}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos los tipos</SelectItem>
|
||||
{(Object.entries(RESOURCE_TYPE_LABELS) as [ResourceType, string][]).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Tabla */}
|
||||
{!isLoading && filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-2">
|
||||
<p className="text-[13px] font-medium text-secondary-foreground">
|
||||
{resources.length === 0 ? 'Sin recursos en el catálogo' : 'Sin resultados para esa búsqueda'}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{resources.length === 0 && 'Creá el primer recurso del equipo'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Recurso</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Costo/h</TableHead>
|
||||
<TableHead>Creado por</TableHead>
|
||||
<TableHead>Procesos</TableHead>
|
||||
<TableHead className="text-right">Acciones</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((resource) => {
|
||||
const count = processCounts[resource.id] ?? 0
|
||||
const isExpanded = expandedResourceId === resource.id
|
||||
return (
|
||||
<>
|
||||
<TableRow key={resource.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ResourceTypeAvatar type={resource.type} size={28} />
|
||||
<span className="text-[13px] font-medium">{resource.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
style={{
|
||||
background: RESOURCE_TYPE_STYLES[resource.type].bg,
|
||||
color: RESOURCE_TYPE_STYLES[resource.type].fg,
|
||||
borderColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{RESOURCE_TYPE_LABELS[resource.type]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[13px]">{formatCostPerHour(resource.costPerHour)}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{resource.creatorName}</TableCell>
|
||||
<TableCell>
|
||||
{count > 0 ? (
|
||||
<button
|
||||
type="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"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
{count} {count === 1 ? 'proceso' : 'procesos'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-[13px]">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{confirmingDeleteId === resource.id ? (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">¿Eliminar?</span>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-[11px]" onClick={() => handleDelete(resource.id)}>
|
||||
Sí
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => setConfirmingDeleteId(null)}>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{fromProcess && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={backToProcess}>
|
||||
<CornerUpLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-48 text-xs">
|
||||
Volver al workspace para asignar este recurso a una actividad
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={() => startEdit(resource)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-destructive" onClick={() => setConfirmingDeleteId(resource.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{isExpanded && (
|
||||
<TableRow key={`${resource.id}-processes`} className="bg-orange-50/40 hover:bg-orange-50/40">
|
||||
<TableCell colSpan={6} className="px-4 pb-3 pt-1">
|
||||
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mb-1.5">
|
||||
Procesos que usan este recurso
|
||||
</p>
|
||||
{expandedProcesses[resource.id] ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{expandedProcesses[resource.id].map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
to="/workspace/$processId"
|
||||
params={{ processId: p.id }}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{p.name}
|
||||
</Link>
|
||||
))}
|
||||
{expandedProcesses[resource.id].length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">Sin procesos encontrados</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Cargando...</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ResourceFormSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
resource={editing}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
src/features/resources/ResourceFormSheet.tsx
Normal file
112
src/features/resources/ResourceFormSheet.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { RESOURCE_TYPE_LABELS } from './resource-types'
|
||||
import type { Resource, ResourceType } from '@/domain/types'
|
||||
|
||||
interface ResourceFormState {
|
||||
name: string
|
||||
type: ResourceType
|
||||
costPerHour: string
|
||||
}
|
||||
|
||||
const EMPTY_FORM: ResourceFormState = { name: '', type: 'role', costPerHour: '' }
|
||||
|
||||
interface ResourceFormSheetProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
resource?: Resource
|
||||
onSave: (resource: Resource) => Promise<void>
|
||||
}
|
||||
|
||||
export function ResourceFormSheet({ open, onOpenChange, resource, onSave }: ResourceFormSheetProps) {
|
||||
const [form, setForm] = useState<ResourceFormState>(EMPTY_FORM)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm(
|
||||
resource
|
||||
? { name: resource.name, type: resource.type, costPerHour: resource.costPerHour.toString() }
|
||||
: EMPTY_FORM
|
||||
)
|
||||
}
|
||||
}, [open, resource])
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.name.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({
|
||||
id: resource?.id ?? uuidv4(),
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
costPerHour: parseFloat(form.costPerHour) || 0,
|
||||
})
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{resource ? 'Editar recurso' : 'Nuevo recurso'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-4 mt-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Nombre</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="ej. Analista Senior"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Tipo</Label>
|
||||
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v as ResourceType }))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.entries(RESOURCE_TYPE_LABELS) as [ResourceType, string][]).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Costo por hora</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={5}
|
||||
placeholder="0.00"
|
||||
value={form.costPerHour}
|
||||
onChange={(e) => setForm((f) => ({ ...f, costPerHour: e.target.value }))}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="mt-6">
|
||||
<Button onClick={handleSubmit} disabled={!form.name.trim() || saving} className="gap-1.5">
|
||||
<Check className="h-4 w-4" />
|
||||
{resource ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
14
src/features/resources/resource-display.tsx
Normal file
14
src/features/resources/resource-display.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { RESOURCE_TYPE_STYLES } from './resource-types'
|
||||
import type { ResourceType } from '@/domain/types'
|
||||
|
||||
export function ResourceTypeAvatar({ type, size = 32 }: { type: ResourceType; size?: number }) {
|
||||
const { icon: Icon, bg, fg } = RESOURCE_TYPE_STYLES[type]
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-full flex-shrink-0"
|
||||
style={{ width: size, height: size, background: bg }}
|
||||
>
|
||||
<Icon style={{ color: fg, width: size * 0.55, height: size * 0.55 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
src/features/resources/resource-types.ts
Normal file
24
src/features/resources/resource-types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { User, Briefcase, Server, Wrench, Package, type LucideIcon } from 'lucide-react'
|
||||
import type { ResourceType } from '@/domain/types'
|
||||
|
||||
export const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
|
||||
person: 'Persona',
|
||||
role: 'Rol',
|
||||
system: 'Sistema',
|
||||
equipment: 'Equipo',
|
||||
supply: 'Insumo',
|
||||
}
|
||||
|
||||
export interface ResourceTypeStyle {
|
||||
icon: LucideIcon
|
||||
bg: string
|
||||
fg: string
|
||||
}
|
||||
|
||||
export const RESOURCE_TYPE_STYLES: Record<ResourceType, ResourceTypeStyle> = {
|
||||
person: { icon: User, bg: '#FEF3E7', fg: '#F59845' },
|
||||
role: { icon: Briefcase, bg: '#FEF3E7', fg: '#D97706' },
|
||||
system: { icon: Server, bg: '#EEF2FF', fg: '#6366F1' },
|
||||
equipment: { icon: Wrench, bg: '#F0FDF4', fg: '#16A34A' },
|
||||
supply: { icon: Package, bg: '#F1F5F9', fg: '#64748B' },
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { Switch } from '@/components/ui/switch'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import { formatCurrency, formatCostPerHour } from '@/lib/format'
|
||||
import type { Activity } from '@/domain/types'
|
||||
|
||||
interface ActivityPanelProps {
|
||||
@@ -254,7 +254,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-700">{resource.name}</p>
|
||||
<p className="text-xs text-slate-400 font-mono">{formatCurrency(resource.costPerHour)}/h</p>
|
||||
<p className="text-xs text-slate-400 font-mono">{formatCostPerHour(resource.costPerHour)}/h</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -318,7 +318,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
<SelectContent>
|
||||
{availableResources.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id} className="text-xs">
|
||||
{r.name} — {formatCurrency(r.costPerHour)}/h
|
||||
{r.name} — {formatCostPerHour(r.costPerHour)}/h
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -587,6 +587,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
|
||||
{/* Guardar */}
|
||||
<Button
|
||||
data-testid="save-activity-btn"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useEffect, useRef, useCallback, type CSSProperties } from 'react'
|
||||
import BpmnViewer from 'bpmn-js/lib/Viewer'
|
||||
import { Maximize2 } from 'lucide-react'
|
||||
import { Maximize2, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
|
||||
const ZOOM_STEP = 1.2
|
||||
const ZOOM_MIN = 0.1
|
||||
const ZOOM_MAX = 4
|
||||
|
||||
export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
|
||||
|
||||
@@ -158,19 +162,19 @@ export function BpmnCanvas({
|
||||
}
|
||||
}, [selectedElementId])
|
||||
|
||||
return (
|
||||
<div className={className} style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||
<button
|
||||
onClick={() => {
|
||||
function handleZoomIn() {
|
||||
const canvas = viewerRef.current?.get('canvas') as any
|
||||
canvas?.zoom('fit-viewport')
|
||||
}}
|
||||
title="Encuadrar diagrama"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
if (!canvas) return
|
||||
canvas.zoom(Math.min(canvas.zoom() * ZOOM_STEP, ZOOM_MAX))
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
const canvas = viewerRef.current?.get('canvas') as any
|
||||
if (!canvas) return
|
||||
canvas.zoom(Math.max(canvas.zoom() / ZOOM_STEP, ZOOM_MIN))
|
||||
}
|
||||
|
||||
const controlButtonStyle: CSSProperties = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 6,
|
||||
@@ -181,11 +185,33 @@ export function BpmnCanvas({
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
zIndex: 10,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||
|
||||
{/* Encuadrar — esquina superior derecha */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const canvas = viewerRef.current?.get('canvas') as any
|
||||
canvas?.zoom('fit-viewport', 'auto')
|
||||
}}
|
||||
title="Encuadrar diagrama"
|
||||
style={{ position: 'absolute', top: 12, right: 12, zIndex: 10, ...controlButtonStyle }}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{/* Zoom in/out — esquina inferior derecha, no interfiere con el botón de encuadre */}
|
||||
<div style={{ position: 'absolute', bottom: 12, left: 12, zIndex: 10, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<button onClick={handleZoomIn} title="Acercar" style={controlButtonStyle}>
|
||||
<ZoomIn className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<button onClick={handleZoomOut} title="Alejar" style={controlButtonStyle}>
|
||||
<ZoomOut className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { formatPercent } from '@/lib/format'
|
||||
import { supabaseAreaRepo } from '@/persistence/supabase/area-repo'
|
||||
import type { Area } from '@/domain/types'
|
||||
|
||||
const CURRENCIES = [
|
||||
{ code: 'USD', label: 'USD — Dólar estadounidense' },
|
||||
@@ -26,8 +28,11 @@ const CURRENCIES = [
|
||||
export function GlobalSettingsPanel() {
|
||||
const { currentProcess, updateProcess } = useProcessStore()
|
||||
const { user } = useAuth()
|
||||
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
|
||||
const [localName, setLocalName] = useState('')
|
||||
const [localClient, setLocalClient] = useState('')
|
||||
const [localAreaId, setLocalAreaId] = useState<string>('__none__')
|
||||
const [areas, setAreas] = useState<Area[]>([])
|
||||
const [areasLoading, setAreasLoading] = useState(false)
|
||||
const [localCurrency, setLocalCurrency] = useState('USD')
|
||||
const [localOverhead, setLocalOverhead] = useState(20)
|
||||
const [localFrequency, setLocalFrequency] = useState(1000)
|
||||
@@ -38,7 +43,18 @@ export function GlobalSettingsPanel() {
|
||||
useEffect(() => {
|
||||
if (!currentProcess) return
|
||||
setLocalName(currentProcess.name)
|
||||
setLocalClient(currentProcess.clientName)
|
||||
setLocalAreaId(currentProcess.areaId ?? '__none__')
|
||||
|
||||
// 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([])
|
||||
}
|
||||
setLocalCurrency(currentProcess.currency)
|
||||
setLocalOverhead(Math.round(currentProcess.overheadPercentage * 100))
|
||||
setLocalFrequency(currentProcess.annualFrequency)
|
||||
@@ -53,7 +69,7 @@ export function GlobalSettingsPanel() {
|
||||
const clampedHorizon = Math.min(10, Math.max(1, localHorizon))
|
||||
await updateProcess({
|
||||
name: localName.trim() || 'Proceso sin nombre',
|
||||
clientName: localClient.trim(),
|
||||
areaId: localAreaId === '__none__' ? null : localAreaId,
|
||||
currency: localCurrency,
|
||||
overheadPercentage: localOverhead / 100,
|
||||
annualFrequency: Math.max(1, localFrequency),
|
||||
@@ -81,16 +97,36 @@ export function GlobalSettingsPanel() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cliente */}
|
||||
{/* Área */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="client-name" className="text-xs font-medium">Cliente</Label>
|
||||
<Input
|
||||
id="client-name"
|
||||
value={localClient}
|
||||
onChange={(e) => { setLocalClient(e.target.value); markDirty() }}
|
||||
placeholder="ej. Empresa ABC"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<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="__none__" 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>
|
||||
|
||||
{/* Moneda */}
|
||||
|
||||
@@ -1,196 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
import { PlusCircle, Pencil, Trash2, Check, X } from 'lucide-react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Plus, ExternalLink } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import type { Resource, ResourceType } from '@/domain/types'
|
||||
|
||||
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
|
||||
role: 'Rol',
|
||||
person: 'Persona',
|
||||
system: 'Sistema',
|
||||
equipment: 'Equipo',
|
||||
supply: 'Insumo',
|
||||
}
|
||||
|
||||
const RESOURCE_TYPE_COLORS: Record<ResourceType, string> = {
|
||||
role: 'bg-slate-100 text-slate-600',
|
||||
person: 'bg-green-100 text-green-700',
|
||||
system: 'bg-purple-100 text-purple-700',
|
||||
equipment: 'bg-amber-100 text-amber-700',
|
||||
supply: 'bg-slate-100 text-slate-700',
|
||||
}
|
||||
|
||||
interface ResourceFormState {
|
||||
name: string
|
||||
type: ResourceType
|
||||
costPerHour: string
|
||||
}
|
||||
|
||||
const EMPTY_FORM: ResourceFormState = { name: '', type: 'role', costPerHour: '' }
|
||||
import { formatCostPerHour } from '@/lib/format'
|
||||
import { RESOURCE_TYPE_LABELS } from '@/features/resources/resource-types'
|
||||
import { ResourceTypeAvatar } from '@/features/resources/resource-display'
|
||||
import { ResourceFormSheet } from '@/features/resources/ResourceFormSheet'
|
||||
import type { Resource } from '@/domain/types'
|
||||
|
||||
export function ResourcesPanel() {
|
||||
const { currentProcess, resources, addResource, updateResource, deleteResource } = useProcessStore()
|
||||
const { currentProcess, activities, resources, addResource } = useProcessStore()
|
||||
const { user } = useAuth()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<ResourceFormState>(EMPTY_FORM)
|
||||
const navigate = useNavigate()
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
|
||||
function startAdd() {
|
||||
setForm(EMPTY_FORM)
|
||||
setEditingId(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
const usedResourceIds = new Set(
|
||||
activities.flatMap((a) => a.assignedResources ?? []).map((ar) => ar.resourceId)
|
||||
)
|
||||
const usedResources = resources.filter((r) => usedResourceIds.has(r.id))
|
||||
const usageCount = (resourceId: string) =>
|
||||
activities.filter((a) => (a.assignedResources ?? []).some((ar) => ar.resourceId === resourceId)).length
|
||||
|
||||
function startEdit(resource: Resource) {
|
||||
setForm({ name: resource.name, type: resource.type, costPerHour: resource.costPerHour.toString() })
|
||||
setEditingId(resource.id)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
setShowForm(false)
|
||||
setEditingId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.name.trim() || !currentProcess) return
|
||||
const costPerHour = parseFloat(form.costPerHour) || 0
|
||||
|
||||
if (editingId) {
|
||||
const existing = resources.find((r) => r.id === editingId)
|
||||
if (!existing) return
|
||||
await updateResource({ ...existing, name: form.name.trim(), type: form.type, costPerHour }, user!.id)
|
||||
} else {
|
||||
const newResource: Resource = {
|
||||
id: uuidv4(),
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
costPerHour,
|
||||
}
|
||||
await addResource(newResource, user!.id)
|
||||
}
|
||||
cancelForm()
|
||||
async function handleSave(resource: Resource) {
|
||||
await addResource(resource, user!.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-700">Recursos del proceso</p>
|
||||
<p className="text-xs text-slate-400">{resources.length} definido{resources.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button size="sm" variant="outline" onClick={startAdd} className="h-7 text-xs gap-1">
|
||||
<PlusCircle className="h-3.5 w-3.5" />
|
||||
<div className="flex items-center justify-between p-4 pb-3 border-b border-slate-100">
|
||||
<p className="text-xs font-medium text-slate-700">Recursos</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setSheetOpen(true)}
|
||||
className="h-7 text-xs gap-1 text-white"
|
||||
style={{ background: '#F59845' }}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Nuevo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Formulario */}
|
||||
{showForm && (
|
||||
<div className="border border-primary/20 bg-primary/5 rounded-lg p-3 space-y-3">
|
||||
<p className="text-xs font-semibold text-slate-700">{editingId ? 'Editar recurso' : 'Nuevo recurso'}</p>
|
||||
{/* Cuerpo */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-3">
|
||||
<p className="text-xs font-medium text-slate-500">
|
||||
Usados en este proceso <span className="text-slate-400">({usedResources.length})</span>
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Nombre</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="ej. Analista Senior"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Tipo</Label>
|
||||
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v as ResourceType }))}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.entries(RESOURCE_TYPE_LABELS) as [ResourceType, string][]).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value} className="text-xs">{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Costo por hora</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={5}
|
||||
placeholder="0.00"
|
||||
value={form.costPerHour}
|
||||
onChange={(e) => setForm((f) => ({ ...f, costPerHour: e.target.value }))}
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!form.name.trim()} className="flex-1 h-7 text-xs gap-1">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{editingId ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={cancelForm} className="h-7 text-xs">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lista */}
|
||||
{resources.length === 0 && !showForm ? (
|
||||
{usedResources.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-xs text-slate-400 mb-2">Sin recursos definidos</p>
|
||||
<p className="text-xs text-slate-300">Los recursos (roles, sistemas, equipos) se asignan a actividades para calcular su costo por hora</p>
|
||||
<p className="text-xs text-slate-400">Ningún recurso asignado a actividades aún</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{resources.map((resource) => (
|
||||
{usedResources.map((resource) => (
|
||||
<div
|
||||
key={resource.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-slate-100 hover:border-slate-200 bg-white group"
|
||||
className="flex items-center gap-2.5 p-3 rounded-lg border border-slate-100 bg-white"
|
||||
>
|
||||
<ResourceTypeAvatar type={resource.type} size={32} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium text-slate-800 truncate">{resource.name}</p>
|
||||
<span className={`shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full ${RESOURCE_TYPE_COLORS[resource.type]}`}>
|
||||
{RESOURCE_TYPE_LABELS[resource.type]}
|
||||
</span>
|
||||
<p className="text-xs text-slate-500 font-mono shrink-0">{formatCostPerHour(resource.costPerHour)}/h</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 font-mono">{formatCurrency(resource.costPerHour)}/hora</p>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-slate-400 hover:text-slate-600"
|
||||
onClick={() => startEdit(resource)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-slate-400 hover:text-destructive"
|
||||
onClick={() => deleteResource(resource.id, user!.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
{RESOURCE_TYPE_LABELS[resource.type]} · {usageCount(resource.id)} actividad{usageCount(resource.id) !== 1 ? 'es' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -198,5 +78,24 @@ export function ResourcesPanel() {
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 pt-3 border-t border-slate-100 space-y-2">
|
||||
<p className="text-[11px] text-slate-400 text-center">Recursos del catálogo global del equipo</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-8 text-xs gap-1.5"
|
||||
onClick={() => navigate({
|
||||
to: '/recursos',
|
||||
search: currentProcess ? { fromProcess: currentProcess.id } : undefined,
|
||||
})}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Ver catálogo completo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ResourceFormSheet open={sheetOpen} onOpenChange={setSheetOpen} onSave={handleSave} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,25 +2,26 @@ import { useEffect, useRef, useState, useCallback, useMemo, useDeferredValue } f
|
||||
import { useParams, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Loader2, BarChart3, Play, AlertTriangle, AlertCircle,
|
||||
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2,
|
||||
PanelRightClose, PanelRightOpen, ArrowLeft, GitMerge, MousePointer2,
|
||||
Check, Pencil
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { BpmnCanvas, type BpmnElementCategory } from './BpmnCanvas'
|
||||
import { ActivityPanel } from './ActivityPanel'
|
||||
import { GatewayPanel, isXorSumValid } from './GatewayPanel'
|
||||
import { ResourcesPanel } from './ResourcesPanel'
|
||||
import { GlobalSettingsPanel } from './GlobalSettingsPanel'
|
||||
import { WorkspaceSkeleton } from './WorkspaceSkeleton'
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
import { useSimulationStore } from '@/store/simulation-store'
|
||||
import { useSimulate } from '../simulation/useSimulate'
|
||||
import { useLibraryStore } from '@/store/library-store'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||
|
||||
type SelectedCategory = 'activity' | 'gateway' | null
|
||||
|
||||
@@ -30,7 +31,6 @@ export function WorkspacePage() {
|
||||
const { currentProcess, activities, isLoading, error, loadProcess, gateways, updateProcess } = useProcessStore()
|
||||
const { isRunning, resultActual, error: simError, selectActivity, loadLatestForProcess, lastPersistedExecutedAt } = useSimulationStore()
|
||||
const { simulate } = useSimulate()
|
||||
const { toast } = useToast()
|
||||
const { user } = useAuth()
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
@@ -50,30 +50,24 @@ export function WorkspacePage() {
|
||||
const [showTagSuggestions, setShowTagSuggestions] = useState(false)
|
||||
const getAllTags = useLibraryStore((state) => state.getAllTags)
|
||||
|
||||
// Usamos un ref para saber si loadProcess ya terminó al menos una vez.
|
||||
// Esto evita que el redirect se dispare en el render inicial donde
|
||||
// isLoading=false y currentProcess=null (antes del primer efecto).
|
||||
const hasLoadedOnce = useRef(false)
|
||||
|
||||
useEffect(() => { loadProcess(processId) }, [processId, loadProcess])
|
||||
|
||||
// Cargar el último executedAt persistido para comparar contra updatedAt (indicador stale)
|
||||
// Cargar el último executedAt persistido para comparar contra updatedAt (indicador stale).
|
||||
// Depende de currentProcess?.id (no del objeto) a propósito: evita re-disparar el efecto
|
||||
// en cada edición de campos del proceso, solo cuando cambia de proceso.
|
||||
useEffect(() => {
|
||||
if (currentProcess) loadLatestForProcess(currentProcess.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProcess?.id, loadLatestForProcess])
|
||||
|
||||
// client_viewer no puede editar — redirigir al reporte
|
||||
useEffect(() => {
|
||||
if (isLoading) { hasLoadedOnce.current = true; return }
|
||||
if (!hasLoadedOnce.current) return // carga no iniciada aún
|
||||
if ((error || !currentProcess) && processId) {
|
||||
toast({
|
||||
title: 'Proceso no encontrado',
|
||||
description: 'El proceso que buscás no existe o fue eliminado.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
navigate({ to: '/' })
|
||||
if (user?.platformRole === 'client_viewer') {
|
||||
void navigate({ to: '/report/$processId', params: { processId } })
|
||||
}
|
||||
}, [isLoading, error, currentProcess, processId, toast, navigate])
|
||||
}, [user, processId, navigate])
|
||||
|
||||
// orgName viene directamente del proceso cargado via JOIN en getById (sin fetch separado)
|
||||
|
||||
// Validación XOR: todos los gateways exclusivos deben sumar 1.0
|
||||
const xorValidation = useMemo(() => {
|
||||
@@ -166,16 +160,42 @@ export function WorkspacePage() {
|
||||
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
// isLoading manda siempre, incluso si currentProcess todavía tiene el proceso
|
||||
// anterior en el store (navegación directa entre dos workspaces distintos).
|
||||
if (isLoading || user?.platformRole === 'client_viewer') {
|
||||
return <WorkspaceSkeleton />
|
||||
}
|
||||
|
||||
if (error || !currentProcess) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="h-screen flex flex-col items-center justify-center gap-4">
|
||||
<AlertCircle className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground max-w-sm text-center">
|
||||
{error ?? 'No se pudo cargar el proceso. Revisá tu conexión.'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => loadProcess(processId)}>
|
||||
Reintentar
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => navigate({ to: '/' })}>
|
||||
Volver a la biblioteca
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !currentProcess) {
|
||||
return null // el useEffect ya inició el redirect con toast
|
||||
// client_viewer ya fue redirigido antes de este punto; solo verificamos client_editor aquí
|
||||
const isClientRole = user?.platformRole === 'client_editor'
|
||||
const backLabel = isClientRole
|
||||
? 'Procesos'
|
||||
: currentProcess.orgName ?? 'Biblioteca'
|
||||
|
||||
function handleBack() {
|
||||
void navigate({
|
||||
to: '/library',
|
||||
search: { org: currentProcess?.orgId ?? undefined },
|
||||
})
|
||||
}
|
||||
|
||||
// Label del tab "elemento" según lo seleccionado
|
||||
@@ -191,14 +211,15 @@ export function WorkspacePage() {
|
||||
|
||||
{/* ── Topbar ─────────────────────────────────────────────────── */}
|
||||
<header className="min-h-12 bg-white border-b border-slate-200 flex items-center px-3 gap-3 shrink-0 z-10">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navigate({ to: '/' })}>
|
||||
<Home className="h-4 w-4" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1 text-xs text-slate-500 h-8 px-2 shrink-0"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{backLabel}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Inicio</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
|
||||
@@ -230,34 +251,9 @@ export function WorkspacePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cliente — editable inline */}
|
||||
{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>
|
||||
{/* Á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>
|
||||
)}
|
||||
|
||||
{/* Tags — chips removibles + input inline */}
|
||||
@@ -483,3 +479,11 @@ export function WorkspacePage() {
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspacePageWithBoundary() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<WorkspacePage />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
45
src/features/workspace/WorkspaceSkeleton.tsx
Normal file
45
src/features/workspace/WorkspaceSkeleton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
|
||||
// Preserva el layout real del workspace (topbar + canvas + panel lateral) mientras
|
||||
// loadProcess() está en curso, en vez de un spinner genérico sobre pantalla en blanco.
|
||||
export function WorkspaceSkeleton() {
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-slate-100 overflow-hidden">
|
||||
<AppHeader />
|
||||
|
||||
{/* Topbar */}
|
||||
<header className="min-h-12 bg-white border-b border-slate-200 flex items-center px-3 gap-3 shrink-0">
|
||||
<div className="h-8 w-8 rounded-md bg-muted animate-pulse" />
|
||||
<div className="h-5 w-px bg-slate-200" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<div className="h-3.5 w-48 rounded bg-muted animate-pulse" />
|
||||
<div className="h-2.5 w-28 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="h-8 w-24 rounded-md bg-muted animate-pulse" />
|
||||
</header>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Canvas BPMN */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="h-full w-full rounded-lg bg-muted animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Panel lateral */}
|
||||
<div className="shrink-0 w-72 bg-white border-l border-slate-200 p-4 space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-7 w-16 rounded-md bg-muted animate-pulse" />
|
||||
<div className="h-7 w-16 rounded-md bg-muted animate-pulse" />
|
||||
<div className="h-7 w-16 rounded-md bg-muted animate-pulse" />
|
||||
</div>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="h-3 w-20 rounded bg-muted animate-pulse" />
|
||||
<div className="h-2.5 w-full rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +1,66 @@
|
||||
// Paleta heatmap: verde → amarillo → rojo
|
||||
// INVARIANTE: estos valores DEBEN coincidir con tailwind.config.js heatmap.*
|
||||
const HEATMAP_LOW = { r: 16, g: 185, b: 129 } // #10b981
|
||||
const HEATMAP_MID = { r: 245, g: 158, b: 11 } // #f59e0b
|
||||
const HEATMAP_HIGH = { r: 239, g: 68, b: 68 } // #ef4444
|
||||
|
||||
// Paleta heatmap: verde → amarillo → naranja → rojo (4 stops, Sprint 5 Etapa 3)
|
||||
// INVARIANTE: estos valores DEBEN coincidir con tailwind.config.js heatmap.* y con
|
||||
// drawHeatmapLegend() en src/lib/export/pdf-sections.ts
|
||||
export const HEATMAP_LOW_HEX = '#10b981'
|
||||
export const HEATMAP_MID_HEX = '#f59e0b'
|
||||
export const HEATMAP_HIGH_MID_HEX = '#f97316'
|
||||
export const HEATMAP_HIGH_HEX = '#ef4444'
|
||||
|
||||
const HEATMAP_STOPS: Array<[number, string]> = [
|
||||
[0.00, HEATMAP_LOW_HEX],
|
||||
[0.33, HEATMAP_MID_HEX],
|
||||
[0.67, HEATMAP_HIGH_MID_HEX],
|
||||
[1.00, HEATMAP_HIGH_HEX],
|
||||
]
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const v = hex.replace('#', '')
|
||||
return {
|
||||
r: parseInt(v.slice(0, 2), 16),
|
||||
g: parseInt(v.slice(2, 4), 16),
|
||||
b: parseInt(v.slice(4, 6), 16),
|
||||
}
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return Math.round(a + (b - a) * t)
|
||||
}
|
||||
|
||||
// t en [0, 1]
|
||||
export function heatmapColor(t: number): string {
|
||||
const clamped = Math.max(0, Math.min(1, t))
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (clamped <= 0.5) {
|
||||
const localT = clamped * 2
|
||||
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
|
||||
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
|
||||
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
|
||||
} else {
|
||||
const localT = (clamped - 0.5) * 2
|
||||
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
|
||||
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
|
||||
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
|
||||
}
|
||||
|
||||
return `rgb(${r}, ${g}, ${b})`
|
||||
function lerpColor(c0: string, c1: string, t: number): { r: number; g: number; b: number } {
|
||||
const rgb0 = hexToRgb(c0)
|
||||
const rgb1 = hexToRgb(c1)
|
||||
return { r: lerp(rgb0.r, rgb1.r, t), g: lerp(rgb0.g, rgb1.g, t), b: lerp(rgb0.b, rgb1.b, t) }
|
||||
}
|
||||
|
||||
export function heatmapColorHex(t: number): string {
|
||||
// Interpolación genérica sobre N stops [t, color-hex] ordenados ascendentemente por t.
|
||||
// t se clampea a [0, 1] antes de ubicar el segmento correspondiente.
|
||||
function interpolateStops(t: number, stops: Array<[number, string]>): { r: number; g: number; b: number } {
|
||||
const clamped = Math.max(0, Math.min(1, t))
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (clamped <= 0.5) {
|
||||
const localT = clamped * 2
|
||||
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
|
||||
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
|
||||
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
|
||||
} else {
|
||||
const localT = (clamped - 0.5) * 2
|
||||
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
|
||||
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
|
||||
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
|
||||
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 = t1 === t0 ? 0 : (clamped - t0) / (t1 - t0)
|
||||
return lerpColor(c0, c1, localT)
|
||||
}
|
||||
}
|
||||
return hexToRgb(stops[stops.length - 1][1])
|
||||
}
|
||||
|
||||
// t en [0, 1] → color hex del gradiente heatmap (verde → amarillo → naranja → rojo)
|
||||
export function intensityToColor(t: number): string {
|
||||
const { r, g, b } = interpolateStops(t, HEATMAP_STOPS)
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Retorna opacidad para colores con alpha (útil para overlays sobre bpmn-js)
|
||||
export function heatmapColor(t: number): string {
|
||||
const { r, g, b } = interpolateStops(t, HEATMAP_STOPS)
|
||||
return `rgb(${r}, ${g}, ${b})`
|
||||
}
|
||||
|
||||
// Retorna color con alpha (útil para overlays sobre bpmn-js)
|
||||
export function heatmapColorWithAlpha(t: number, alpha = 0.75): string {
|
||||
const clamped = Math.max(0, Math.min(1, t))
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (clamped <= 0.5) {
|
||||
const localT = clamped * 2
|
||||
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
|
||||
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
|
||||
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
|
||||
} else {
|
||||
const localT = (clamped - 0.5) * 2
|
||||
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
|
||||
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
|
||||
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
|
||||
}
|
||||
|
||||
const { r, g, b } = interpolateStops(t, HEATMAP_STOPS)
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
@@ -78,7 +71,10 @@ export function getContrastText(t: number): 'white' | 'black' {
|
||||
|
||||
// ─── Helpers para el heatmap del reporte ──────────────────────────────────────
|
||||
|
||||
export type HeatmapMode = 'relative' | 'absolute'
|
||||
// percentile: posición relativa por rango entre todas las actividades del proceso (no sufre
|
||||
// distorsión por outliers).
|
||||
// minmax: (costo - mínimo) / (máximo - mínimo) — refleja valores absolutos, se comprime con outliers.
|
||||
export type HeatmapMode = 'percentile' | 'minmax'
|
||||
|
||||
export interface ActivityCostData {
|
||||
expectedTotalCost: number
|
||||
@@ -101,9 +97,20 @@ export function hasSignificantCostVariance(activities: ActivityCostData[]): bool
|
||||
return (max - min) / max > VARIANCE_THRESHOLD
|
||||
}
|
||||
|
||||
// Normalización por percentil: posición de `current` en el ranking de costos de `all`.
|
||||
// rank = cantidad de actividades estrictamente más baratas. Ties reciben el mismo rango
|
||||
// (densidad uniforme). No sufre distorsión por outliers, a diferencia de minmax.
|
||||
function percentileNorm(current: ActivityCostData, all: ActivityCostData[]): number {
|
||||
const n = all.length
|
||||
if (n <= 1) return 0.5
|
||||
if (current.expectedTotalCost < 0) return 0 // costo negativo = "más barato posible"
|
||||
|
||||
const currentCost = current.expectedTotalCost
|
||||
const rank = all.filter((a) => a.expectedTotalCost < currentCost).length
|
||||
return rank / (n - 1)
|
||||
}
|
||||
|
||||
// Calcula t ∈ [0,1] para una actividad dada la lista completa y el modo.
|
||||
// Modo relativo: normaliza sobre la actividad más costosa (t=1 = la que más pesa).
|
||||
// Modo absoluto: normaliza sobre el rango min-max del proceso.
|
||||
// Pre-condición: llamar solo cuando hasSignificantCostVariance = true.
|
||||
export function computeActivityT(
|
||||
activity: ActivityCostData,
|
||||
@@ -112,9 +119,8 @@ export function computeActivityT(
|
||||
): number {
|
||||
if (allActivities.length === 0) return 0
|
||||
|
||||
if (mode === 'relative') {
|
||||
const maxPercent = Math.max(...allActivities.map((a) => a.percentOfTotal), 0.001)
|
||||
return Math.max(0, Math.min(1, activity.percentOfTotal / maxPercent))
|
||||
if (mode === 'percentile') {
|
||||
return percentileNorm(activity, allActivities)
|
||||
}
|
||||
|
||||
const costs = allActivities.map((a) => a.expectedTotalCost)
|
||||
@@ -132,19 +138,21 @@ export function activityColor(
|
||||
mode: HeatmapMode
|
||||
): string {
|
||||
if (!hasSignificantCostVariance(allActivities)) return NEUTRAL_HEATMAP_COLOR
|
||||
return heatmapColorHex(computeActivityT(activity, allActivities, mode))
|
||||
return intensityToColor(computeActivityT(activity, allActivities, mode))
|
||||
}
|
||||
|
||||
// Valores de los extremos para la leyenda del heatmap
|
||||
// Valores de los extremos para la leyenda del heatmap.
|
||||
// percentile: los extremos del gradiente representan el rango (0 = más barata, 100 = más cara),
|
||||
// no un costo o porcentaje literal — el rank no es lineal respecto al costo.
|
||||
// minmax: los extremos son el costo mínimo y máximo reales del proceso.
|
||||
export function heatmapLegendBounds(
|
||||
allActivities: ActivityCostData[],
|
||||
mode: HeatmapMode
|
||||
): { min: number; mid: number; max: number; unit: 'currency' | 'percent' } {
|
||||
if (allActivities.length === 0) return { min: 0, mid: 50, max: 100, unit: 'percent' }
|
||||
): { min: number; mid: number; max: number; unit: 'currency' | 'rank' } {
|
||||
if (allActivities.length === 0) return { min: 0, mid: 50, max: 100, unit: 'rank' }
|
||||
|
||||
if (mode === 'relative') {
|
||||
const maxPercent = Math.max(...allActivities.map((a) => a.percentOfTotal))
|
||||
return { min: 0, mid: maxPercent / 2, max: maxPercent, unit: 'percent' }
|
||||
if (mode === 'percentile') {
|
||||
return { min: 0, mid: 50, max: 100, unit: 'rank' }
|
||||
}
|
||||
|
||||
const costs = allActivities.map((a) => a.expectedTotalCost)
|
||||
|
||||
@@ -12,9 +12,46 @@ import {
|
||||
} from './pdf-sections-roi'
|
||||
import { drawSharedHeader, applyFootersToAllPages } from './pdf-sections-shared'
|
||||
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
|
||||
import logoColorUrl from '../../../assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png?url'
|
||||
|
||||
export type ReportTab = 'actual' | 'automatizado' | 'roi'
|
||||
|
||||
interface LoadedLogo {
|
||||
base64: string
|
||||
pixelWidth: number
|
||||
pixelHeight: number
|
||||
}
|
||||
|
||||
// Convierte el logo a base64 para incrustarlo en el PDF vía doc.addImage().
|
||||
// Si el fetch falla (ej. entorno sin red), el caller usa .catch(() => undefined)
|
||||
// y drawSharedHeader cae al fallback de texto "InQ ROI" — el export nunca se bloquea por esto.
|
||||
//
|
||||
// Redimensiona antes de convertir: el asset fuente es 724×474px, pero en el PDF se
|
||||
// usa a ~15mm de ancho. jsPDF embebe PNG con canal alpha como bitmap RGBA sin
|
||||
// recomprimir (no reutiliza la compresión del PNG original) — sin este resize, cada
|
||||
// export engordaba ~1.3MB (de ~60KB a ~1.4MB) solo por el logo. 320px de ancho es
|
||||
// suficiente para nitidez en impresión a ese tamaño físico.
|
||||
//
|
||||
// Retorna las dimensiones en píxeles del bitmap resultante junto al base64: drawSharedHeader
|
||||
// las necesita para calcular el alto proporcional real (724×474px ≈ ratio 1.53:1, no 3.56:1
|
||||
// como asumía el hardcode original de 32×9mm — eso aplastaba el logo horizontalmente).
|
||||
async function loadImageBase64(url: string): Promise<LoadedLogo> {
|
||||
const response = await fetch(url)
|
||||
const blob = await response.blob()
|
||||
const bitmap = await createImageBitmap(blob)
|
||||
|
||||
const targetW = 320
|
||||
const targetH = Math.round(targetW * (bitmap.height / bitmap.width))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = targetW
|
||||
canvas.height = targetH
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('No se pudo obtener contexto 2D del canvas')
|
||||
ctx.drawImage(bitmap, 0, 0, targetW, targetH)
|
||||
|
||||
return { base64: canvas.toDataURL('image/png'), pixelWidth: targetW, pixelHeight: targetH }
|
||||
}
|
||||
|
||||
export interface PdfExportParams {
|
||||
process: Process
|
||||
simulation: Simulation
|
||||
@@ -35,13 +72,15 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
|
||||
|
||||
const logo = await loadImageBase64(logoColorUrl).catch(() => undefined)
|
||||
|
||||
if (tab === 'roi') {
|
||||
await exportRoiPdf(doc, autoTable, process, simulation, activities, currency)
|
||||
await exportRoiPdf(doc, autoTable, process, simulation, activities, currency, logo)
|
||||
} else {
|
||||
const result = tab === 'automatizado'
|
||||
? (simulation.resultAutomated ?? simulation.result)
|
||||
: simulation.result
|
||||
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency, tab as 'actual' | 'automatizado', activities)
|
||||
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency, tab as 'actual' | 'automatizado', activities, logo)
|
||||
}
|
||||
|
||||
const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
|
||||
@@ -62,7 +101,8 @@ async function exportScenarioPdf(
|
||||
heatmapImageData: string | null,
|
||||
currency: string,
|
||||
tab: 'actual' | 'automatizado',
|
||||
activities: Activity[] = []
|
||||
activities: Activity[] = [],
|
||||
logo?: LoadedLogo
|
||||
): Promise<void> {
|
||||
const docAny = doc as any
|
||||
const scenarioLabel = tab === 'actual' ? 'Escenario actual' : 'Escenario automatizado'
|
||||
@@ -75,7 +115,14 @@ async function exportScenarioPdf(
|
||||
keywords: 'bpmn, costos, simulación',
|
||||
})
|
||||
|
||||
const headerOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
|
||||
const headerOpts = {
|
||||
processName: process.name,
|
||||
clientName: process.clientName || undefined,
|
||||
simulatedAt: simulation.executedAt,
|
||||
logoBase64: logo?.base64,
|
||||
logoPixelWidth: logo?.pixelWidth,
|
||||
logoPixelHeight: logo?.pixelHeight,
|
||||
}
|
||||
|
||||
// ── Página 1 (portrait): Header denso + metadata + KPI grid (Etapa 8) ────────
|
||||
drawScenarioPage1(docAny, tab, process, simulation.executedAt, result, currency)
|
||||
@@ -164,7 +211,8 @@ async function exportRoiPdf(
|
||||
process: Process,
|
||||
simulation: Simulation,
|
||||
activities: Activity[],
|
||||
currency: string
|
||||
currency: string,
|
||||
logo?: LoadedLogo
|
||||
): Promise<void> {
|
||||
const result = simulation.result
|
||||
const resultAutomated = simulation.resultAutomated ?? result
|
||||
@@ -185,7 +233,14 @@ async function exportRoiPdf(
|
||||
keywords: 'bpmn, roi, automatización, payback',
|
||||
})
|
||||
|
||||
const roiHeaderOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
|
||||
const roiHeaderOpts = {
|
||||
processName: process.name,
|
||||
clientName: process.clientName || undefined,
|
||||
simulatedAt: simulation.executedAt,
|
||||
logoBase64: logo?.base64,
|
||||
logoPixelWidth: logo?.pixelWidth,
|
||||
logoPixelHeight: logo?.pixelHeight,
|
||||
}
|
||||
|
||||
// ── Página 1: Rediseño completo InQ (Etapa 6) ───────────────────────────────
|
||||
drawRoiPage1(doc, process, roi, simulation.executedAt, result.perActivity, resultAutomated.perActivity, currency)
|
||||
|
||||
@@ -45,42 +45,21 @@ export function drawRoiPage1(
|
||||
// ── 5.1 Header denso de página 1 ─────────────────────────────────────────────
|
||||
|
||||
function _drawDenseHeader(
|
||||
doc: DocLike, pageW: number, m: number, cW: number, y: number
|
||||
doc: DocLike, _pageW: number, m: number, cW: number, y: number
|
||||
): number {
|
||||
const right = pageW - m
|
||||
|
||||
// Izquierda: "InQ ROI" 22pt bold naranja
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(22)
|
||||
doc.setTextColor(...C.heroOrange)
|
||||
doc.text('InQ ROI', m, y)
|
||||
|
||||
// Derecha: "UN PRODUCTO DE" 9pt slate-400
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textDisabled)
|
||||
doc.text('UN PRODUCTO DE', right, y, { align: 'right' } as Record<string, unknown>)
|
||||
y += 9
|
||||
|
||||
// Izquierda: subtítulo 9pt slate-400 (margin-top 4mm ya incluido en line-height)
|
||||
// Izquierda: subtítulo 9pt slate-400
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textDisabled)
|
||||
doc.text('ANÁLISIS DE RETORNO DE INVERSIÓN', m, y)
|
||||
|
||||
// Derecha: "InQ" 16pt bold slate-600
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(16)
|
||||
doc.setTextColor(...PDF_COLORS.textSecondary)
|
||||
doc.text('InQ', right, y, { align: 'right' } as Record<string, unknown>)
|
||||
y += 6
|
||||
|
||||
// Derecha: "InQuality" 9pt slate-500
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textTertiary)
|
||||
doc.text('InQuality', right, y, { align: 'right' } as Record<string, unknown>)
|
||||
y += 10
|
||||
y += 16
|
||||
|
||||
// Border-bottom naranja 0.8mm
|
||||
doc.setDrawColor(...C.heroOrange)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user