Compare commits
33 Commits
9277143822
...
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 |
387
CHANGELOG.md
387
CHANGELOG.md
@@ -1,262 +1,129 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Historial de cambios del proyecto. Formato basado en [Keep a Changelog](https://keepachangelog.com/es/).
|
All notable changes to InQ ROI will be documented in this file.
|
||||||
Los cambios se describen desde la perspectiva del código: qué archivos cambiaron,
|
|
||||||
por qué, y qué decisiones de diseño están implícitas — para que futuras
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
modificaciones profundas tengan contexto suficiente.
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
---
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
## [Unreleased] — Sprint 1: ROI y Escenarios de Automatización
|
- `RequireRole` componente wrapper para guards de rol declarativos (Approach B — sin router context) (Sprint 8)
|
||||||
|
- Edge Function `delete-user`: hard delete permanente de usuario en auth.users + public.users (Sprint 8)
|
||||||
### Estado
|
- Edge Function `list-users`: lista usuarios con email desde auth.users vía service_role (Sprint 8)
|
||||||
En construcción. Etapas 1–4 completas; Etapas 5–6 pendientes.
|
- UsersPage: columna "Email" entre Nombre y Rol (via Edge Function list-users) (Sprint 8)
|
||||||
|
- Migración 018: policy INSERT en `processes` extendida para `client_editor` en su org (Sprint 8)
|
||||||
### Etapa 1 — Cimientos del dominio (completada)
|
- ImportPage: `client_editor` puede importar BPMN; proceso se crea con `orgId` de su cuenta (Sprint 8)
|
||||||
|
- LibraryPage: botón "Importar BPMN" visible para `client_editor`; empty state con call-to-action de importar (Sprint 8)
|
||||||
**Nuevos campos en el modelo de dominio** (`src/domain/types.ts`):
|
|
||||||
|
### Changed
|
||||||
- `Activity` gana: `automatable: boolean`, `automatedCostFixed: number`, `automatedTimeMinutes: number`
|
- AdminLayout: guard useEffect + spinner → RequireRole wrapper (sin flash de contenido protegido) (Sprint 8)
|
||||||
- `Process` gana: `annualFrequency: number`, `analysisHorizonYears: number`, `automationInvestment: number`
|
- ImportPage: guard useEffect → RequireRole wrapper (Sprint 8)
|
||||||
- Nuevo tipo `Scenario = 'actual' | 'automated'`
|
- UsersPage: "Eliminar" (soft delete / revoke-user) → "Eliminar permanentemente" (hard delete / delete-user) (Sprint 8)
|
||||||
- `SimulationInput` acepta `scenario?: Scenario` (opcional, default `'actual'` para retrocompatibilidad)
|
|
||||||
- `Simulation` gana `resultAutomated?: SimulationResult` (opcional, para retrocompatibilidad con snapshots viejos)
|
### Fixed
|
||||||
|
- ImportPage: texto de marca corregido — "Process Cost Platform · MVP Fase 0" → "InQ ROI" (Sprint 8)
|
||||||
**Migración Dexie v1 → v2** (`src/persistence/db.ts`):
|
- ImportPage: párrafo "Sin backend · Sin registro · Datos guardados localmente" eliminado (obsoleto desde Sprint 4) (Sprint 8)
|
||||||
|
- OrganizationsPage: InQuality visible en lista con badge naranja — quitar filtro is_provider (Sprint 8)
|
||||||
- `version(2).upgrade()` popula defaults en registros existentes:
|
- WorkspacePage: back button pasa ?org=orgId para scroll contextual al volver a biblioteca (Sprint 8)
|
||||||
- Activities: `automatable=false`, `automatedCostFixed=0`, `automatedTimeMinutes=0`
|
- LibraryPage: scroll automático a sección de org al recibir ?org param desde workspace (Sprint 8)
|
||||||
- Processes: `annualFrequency=1000`, `analysisHorizonYears=3`, `automationInvestment=0`
|
- router.tsx: redirect de settingsRoute pasa search: { org: undefined } para compatibilidad con validateSearch (Sprint 8)
|
||||||
- Sin cambio de índices — solo nuevos campos con defaults seguros.
|
|
||||||
|
### Added (Etapas 1-4)
|
||||||
**Nuevo módulo de ROI** (`src/domain/roi.ts`):
|
- GlobalSettingsPanel: selector de área reemplaza campo libre "Cliente" (Sprint 8)
|
||||||
|
- WorkspacePage topbar: display de `areaName` de solo lectura reemplaza edición inline de cliente (Sprint 8)
|
||||||
- Función pura `calculateRoi(input: RoiInput): RoiResult`
|
- OrganizationDetailPage: tercer tab "Áreas" con CRUD completo — crear, renombrar, eliminar con confirmación (Sprint 8)
|
||||||
- Fórmulas nominales simples (sin VPN ni TIR — decisión de diseño ADR-014)
|
- LibraryPage: agrupación org → área con headers colapsables para admin (Sprint 8)
|
||||||
- Edge cases explicitados: inversión cero, ahorro negativo, costo actual cero (evitar div/0)
|
- Settings ⚙ oculto para roles cliente + guard beforeLoad en /settings (Sprint 8)
|
||||||
- `paybackMonths = Infinity` cuando no hay ahorro, `roiAnnualPercent = Infinity` cuando inversión=0 y ahorro>0
|
- Interface `Area` en domain/types.ts (Sprint 8)
|
||||||
|
- Campos `areaId`/`areaName` en `Process` (Sprint 8)
|
||||||
**Motor de simulación dual** (`src/domain/simulation.ts`):
|
- Repositorio `supabaseAreaRepo` con CRUD completo para tabla `areas` (Sprint 8)
|
||||||
|
- `fromRow`/`toRow`/`updateEditable`/`getById` en process-repo extendidos con `area_id` (Sprint 8)
|
||||||
- Acepta `scenario` en `SimulationInput`
|
|
||||||
- En `'automated'`: actividades con `automatable=true` usan `automatedCostFixed`/`automatedTimeMinutes`
|
## [0.8.0] - 2026-07-07
|
||||||
- Las no-automatable mantienen sus costos originales en ambos escenarios
|
### Added
|
||||||
- La probabilidad de ejecución NO cambia entre escenarios — solo costos y tiempos
|
- CHANGELOG.md con historial de versiones
|
||||||
|
- AppFooter con versión visible en todas las páginas
|
||||||
**Simulation store refactorizado** (`src/store/simulation-store.ts`):
|
- Conventional Commits adoptados como estándar de commits
|
||||||
|
|
||||||
- `result` (single) → `resultActual + resultAutomated + lastSimulatedAt`
|
## [0.7.0] - 2026-07-07
|
||||||
- Nueva acción `persistResults(processId, actual, automated)`: guarda en Dexie + actualiza memoria en un solo paso atómico
|
### Added
|
||||||
- Nueva acción `invalidateResults()`: pone ambos a null
|
- Multi-tenancy: tabla `organizations`, 4 roles de plataforma (platform_admin, member, client_editor, client_viewer)
|
||||||
- Suscripción reactiva a `process-store`: cualquier cambio en `activities` o `currentProcess` invalida ambos resultados automáticamente
|
- Edge Functions `invite-user` y `revoke-user` para gestión de acceso
|
||||||
- **Dependencia intencional documentada**: `simulation-store` importa `process-store` en una dirección (sin inversa → sin ciclo)
|
- Panel de administración `/admin` con gestión de Organizations y Users
|
||||||
|
- Login email/password y página `/reset-password` para flujo de invitación
|
||||||
**useSimulate refactorizado** (`src/features/simulation/useSimulate.ts`):
|
- Back button contextual en workspace (muestra nombre del cliente via JOIN en getById)
|
||||||
|
- Política RLS `simulations_update` para que client_editor pueda guardar simulaciones
|
||||||
- Corre las dos simulaciones en el mismo ciclo y llama `persistResults` del store (eliminando el import directo de `simulationRepo` desde un hook de feature)
|
|
||||||
- Retorna `{ resultActual, resultAutomated }` en lugar de un único result
|
### Fixed
|
||||||
|
- Race condition en AuthContext: `isProfileLoaded` como guard antes de renderizar contenido rol-dependiente
|
||||||
**Infraestructura ESLint** (`eslint.config.js` — archivo nuevo):
|
- `syncProfileFromDB` ahora lee `platform_role` y `org_id` desde DB (no inferido por dominio de email)
|
||||||
|
- `normalizeGroupLabel()`: limpia artículos iniciales de labels de grupos con datos corruptos
|
||||||
- Primer config de ESLint del proyecto (antes no existía)
|
|
||||||
- Regla `no-restricted-imports`: prohíbe importar `persistence/repositories` desde fuera de `store/`
|
## [0.6.0] - 2026-06-24
|
||||||
- Overrides para `store/**`, `features/import/**`, `features/report/useReportData.ts`, `tests/**`
|
### Added
|
||||||
- Regla `react-hooks/set-state-in-effect: 'off'`: patrón de sync local state con useEffect es válido en este codebase
|
- Logo InQuality en header de PDFs (isotipo, páginas 2+, fallback a texto)
|
||||||
- **Por qué importa**: si alguien llama `activityRepo.save()` directamente desde una feature sin pasar por el store, los resultados de simulación quedan stale (la suscripción reactiva no dispara)
|
- Panel de análisis de sensibilidad en tab ROI: sliders de volumen e inversión, recálculo en tiempo real
|
||||||
|
- Doble línea en CumulativeSavingsChart (base gris dashed + ajustada naranja con área sombreada)
|
||||||
**Tests nuevos** (32 tests):
|
- `whitespace-nowrap` en MetricCell de columna Eficiencia
|
||||||
|
|
||||||
- `tests/domain/roi.test.ts`: 16 casos cubriendo happy path, inversión cero, ahorro negativo, NaN
|
### Fixed
|
||||||
- `tests/integration/dexie-migration.test.ts`: 6 casos de persistencia de los nuevos campos con valores default y no-default
|
- `ProfileSheet.getAccessToken()` leía clave inexistente (`'supabase.auth.token'`); el guardado de perfil nunca llegaba a Supabase desde Sprint 4. Fix: calcular `sb-${projectRef}-auth-token`
|
||||||
- `tests/store/simulation-store-invalidation.test.ts`: 7 casos de la invariante de invalidación dual
|
|
||||||
- `tests/integration/sprint1-flow.test.ts`: 11 casos del flujo integrado BPMN → sim actual → sim automated → calculateRoi → KPIs
|
## [0.5.0] - 2026-06-23
|
||||||
- `tests/domain/simulation.test.ts`: +4 casos de `scenario=automated` con costos en cero, sin NaN, probabilidad invariante
|
### Added
|
||||||
- `tests/integration/bpmn-collaboration.test.ts`: 3 casos para BPMNs de colaboración con múltiples pools
|
- Fix crítico: workspace loading — race condition auth/processId resuelto con `isProfileLoaded`
|
||||||
|
- Controles de zoom BPMN: fit-viewport con centering correcto, botones zoom-in/zoom-out/fit
|
||||||
**Archivos de test actualizados** (fixtures con nuevos campos):
|
- Heatmap con gradiente continuo de 4 stops + toggle percentile/min-max persistido
|
||||||
|
- "Ver procesos por recurso" en catálogo de recursos
|
||||||
- `tests/integration/simulation-engine.test.ts`
|
- Campo "empresa" en perfil de usuario
|
||||||
- `tests/integration/gateway-validation.test.ts`
|
- Columna "Eficiencia de automatización" en ComparisonTable (+ columna "Ahorro esperado")
|
||||||
- `tests/integration/indexeddb-persistence.test.ts`
|
|
||||||
- `tests/features/report/components.test.tsx`
|
### Fixed
|
||||||
- `tests/features/report/derivations.test.ts`
|
- Flakiness en botón "Guardar cambios": debounce + Web Locks en process-store
|
||||||
- `tests/lib/export/measure-sizes.test.ts`
|
- Specs E2E migradas de Dexie a Supabase
|
||||||
- `tests/lib/export/csv-export.test.ts`
|
|
||||||
- `tests/lib/export/pdf-export.test.ts`
|
## [0.4.0] - 2026-06-18
|
||||||
- `tests/setup.ts`: mocks globales `ResizeObserver` y `PointerEvent` para jsdom (necesario desde Sprint 1 para tests con Slider de Radix)
|
### Added
|
||||||
|
- Autenticación Google Workspace OAuth + Supabase
|
||||||
### Etapa 2 — UI de Configuración (completada)
|
- Migración completa IndexedDB (Dexie) → PostgreSQL (Supabase)
|
||||||
|
- Row Level Security con `is_platform_admin()` y políticas granulares
|
||||||
**Switch component** (`src/components/ui/switch.tsx` — nuevo):
|
- Catálogo global de recursos (sin processId)
|
||||||
|
- Identidad en UI: avatar/dropdown AppHeader, "Actualizado por" en proceso
|
||||||
- Implementación Tailwind pura sin `@radix-ui/react-switch`
|
- ResourceCatalogPage + ResourceFormSheet
|
||||||
- `role="switch"` + `aria-checked` + `aria-label` para accesibilidad
|
- Fix Web Locks API deadlock (processLock)
|
||||||
- Transiciones CSS: `transition-colors duration-200` en el fondo, `transition-transform duration-200` en el knob
|
- Fix `.maybeSingle()` en simulation-repo
|
||||||
- **Decisión**: no instalar `@radix-ui/react-switch` para no agregar dependencia — el comportamiento visual es idéntico
|
- Fix `formatCostPerHour()` precisión dinámica
|
||||||
|
- Fix Google OAuth `prompt: 'select_account'`
|
||||||
**ActivityPanel ampliado** (`src/features/workspace/ActivityPanel.tsx`):
|
- Catálogo con contexto de proceso (banner + query param fromProcess)
|
||||||
|
|
||||||
- Nueva sección "Automatización" al pie del panel, separada con `<Separator>`
|
## [0.3.0] - 2026-05-27
|
||||||
- Toggle Switch (`automatable`) oculta/muestra los campos con `transition-all duration-200 overflow-hidden max-h-{0|80}`
|
### Added
|
||||||
- **Limitación conocida**: el max-height trick no anima a la altura real del contenido. Ver TODO.md para mejora con `useRef + scrollHeight`
|
- Biblioteca de procesos con grupos/tags/búsqueda (Dexie v4)
|
||||||
- Inputs numéricos `automatedCostFixed` y `automatedTimeMinutes` con mismo patrón que los existentes (placeholder cuando valor=0)
|
- Indicador de simulación desactualizada ("Hay cambios sin simular")
|
||||||
- Helper ámbar de transparencia metodológica:
|
- Zoom BPMN fit-viewport
|
||||||
- Aparece cuando `automatable=true && cost===0 && time===0`
|
- Recursos TO-BE en escenario automatizado (motor + UI + reporte + PDF)
|
||||||
- **Siempre está en el DOM** (no conditional render) — la visibilidad se controla con `opacity-0/100 + max-h-0/20 + transition-all duration-150`
|
- Branding InQ ROI en todas las páginas (AppHeader + favicon + logos)
|
||||||
- `aria-hidden` refleja el estado de visibilidad
|
- Kit de socialización del equipo: guía de usuario, guía de pre-venta, presentación PPTX
|
||||||
- **Por qué importa**: con conditional render (`&&`) el helper desaparece instantáneamente al tipear — sin transición. Al mantenerlo en el DOM, el fade de 150ms se aplica correctamente
|
|
||||||
|
## [0.2.0] - 2026-05-20
|
||||||
**GlobalSettingsPanel ampliado** (`src/features/workspace/GlobalSettingsPanel.tsx`):
|
### Added
|
||||||
|
- Modelo de recursos `utilizationMinutes + units` (migración Dexie v3)
|
||||||
- Nueva sección "Análisis de Impacto" al pie del panel
|
- ActivityPanel con cálculo de costo en tiempo real
|
||||||
- `annualFrequency`: input numérico, min=1, sugerencias de referencia en texto helper
|
- Reporte web con filas expandibles de desglose de recursos
|
||||||
- `analysisHorizonYears`: Slider de 1 a 10 años con valor en texto ("3 años")
|
- Edición inline de proceso/cliente
|
||||||
- `automationInvestment`: input numérico, default placeholder vacío cuando valor=0
|
- Tabla de recursos en PDF (página condicional)
|
||||||
- La validación de rango de `analysisHorizonYears` (clamp 1-10) ocurre en `handleSave`, no en `onChange`
|
|
||||||
|
## [0.1.5] - 2026-05-18
|
||||||
**BpmnCanvas ampliado** (`src/features/workspace/BpmnCanvas.tsx`):
|
### Added
|
||||||
|
- Identidad visual completa: paleta naranja InQ #F59845, gradiente → magenta #ED3E8F
|
||||||
- Nueva prop `automatableElementIds?: string[]`
|
- PDFs de clase profesional: 3 documentos (Actual, Automatizado, ROI)
|
||||||
- `syncAutomationOverlays()`: callback memoizado con `useCallback([automatableElementIds])`
|
- Badge ⚡ en canvas BPMN para actividades automatizables
|
||||||
- Limpia overlays anteriores con `overlays.remove({ type: 'automation-indicator' })`
|
- ROI con 5 KPIs
|
||||||
- Re-agrega un badge por cada elementId en la lista
|
- Sistema de componentes establecido
|
||||||
- Se llama después de `importXML` (en el `.then()`) y en un `useEffect` separado cuando cambia la lista
|
|
||||||
- **Posicionamiento del badge**: `{ top: -8, left: element.width - 12 }`
|
## [0.1.0] - 2026-05-01
|
||||||
- Obtenido via `elementRegistry.get(elementId)` con fallback `width ?? 100`
|
### Added
|
||||||
- **No usar** `position.right` de diagram-js: con `right: -8`, la fórmula interna `left = right * -1 + width` da `left = 8 + width`, colocando el badge enteramente fuera del nodo sin overlap de esquina
|
- MVP: importar BPMN 2.0, parametrizar actividades, simular costos, exportar PDF
|
||||||
- Con `left = width - 12`: badge left está 12px antes del borde derecho, su borde derecho queda 8px fuera → overlap visible en la esquina
|
- Motor de simulación AS-IS / TO-BE (Modelo A)
|
||||||
- **Badge HTML**: círculo 20×20px, fondo `#fffbeb` (amber-50), borde `#e2e8f0`, sombra `0 1px 3px rgba(0,0,0,0.12)`, ícono Bot Lucide en `#f59e0b` como SVG inline
|
- Heatmap de costos en canvas BPMN
|
||||||
- `pointer-events: none` para no interferir con clicks en el nodo
|
- Reporte web básico con tabla de actividades y gráfico de barras
|
||||||
- `title` nativo del browser como tooltip
|
|
||||||
- **Por qué `#fffbeb` y no `#ffffff`**: el fondo de los nodos BPMN también es blanco, el fondo ámbar le da identidad cromática propia y hace visible el círculo por contraste
|
|
||||||
|
|
||||||
**WorkspacePage** (`src/features/workspace/WorkspacePage.tsx`):
|
|
||||||
|
|
||||||
- Destructura `activities` del process-store
|
|
||||||
- Calcula `automatableElementIds` con `useMemo`
|
|
||||||
- Pasa `useDeferredValue(automatableElementIds)` al BpmnCanvas (evita cascada de re-renders al editar el panel)
|
|
||||||
|
|
||||||
**AutomationScenarioNote** (`src/features/report/AutomationScenarioNote.tsx` — nuevo):
|
|
||||||
|
|
||||||
- Componente de leyenda para el tab "Automatizado" del reporte
|
|
||||||
- Preparado y listo para ser colocado por Etapa 3 — no tiene lógica propia
|
|
||||||
|
|
||||||
### Etapa 3 — Motor dual y reporte con tabs (completada)
|
|
||||||
|
|
||||||
**ReportPage ampliada** (`src/features/report/ReportPage.tsx`):
|
|
||||||
|
|
||||||
- La página ahora tiene 3 tabs: **Actual** | **Automatizado** | **Comparación + ROI**
|
|
||||||
- El contenido existente (KpiBar, heatmap, 3 gráficos, tabla de actividades) pasa intacto al tab "Actual"
|
|
||||||
- Los tabs "Automatizado" y "ROI" se deshabilitan si la simulación no tiene `resultAutomated` (snapshot viejo) — en ese caso se muestra `NeedsResimulate` que guía al usuario a re-simular
|
|
||||||
- `useMemo` para `automatableIds` y `roi` se declaran antes de los early returns (cumple reglas de hooks)
|
|
||||||
- Los refs de heatmap son independientes por tab (`heatmapRef` / `heatmapRefAuto`) — el tab "Actual" sigue siendo el fuente de imagen para el export PDF
|
|
||||||
- Export PDF/CSV sin cambios — Etapa 4 los ampliará
|
|
||||||
|
|
||||||
**Tab "Automatizado"**:
|
|
||||||
|
|
||||||
- Mismo layout que "Actual": KpiBar → heatmap con cross-linking → 3 gráficos → tabla
|
|
||||||
- Usa `simulation.resultAutomated` como fuente de datos
|
|
||||||
- `AutomationScenarioNote` aparece al pie del heatmap (componente preparado en Etapa 2)
|
|
||||||
- Heatmap y tabla tienen su propio estado `selectedIdAuto` — cross-linking funciona independientemente del tab "Actual"
|
|
||||||
|
|
||||||
**Tab "Comparación + ROI"**:
|
|
||||||
|
|
||||||
- `RoiKpiBar` (`src/features/report/RoiKpiBar.tsx` — nuevo): 5 KPI cards en grid 2×5 (lg)
|
|
||||||
- Ahorro por ejecución (con % sobre costo actual)
|
|
||||||
- Ahorro anual
|
|
||||||
- Payback (meses o años según magnitud; "Inmediato" si inversión=0; "No se recupera" si Infinity)
|
|
||||||
- Ahorro acumulado neto a N años
|
|
||||||
- ROI anual % ("∞%" si inversión=0 y ahorro>0)
|
|
||||||
- Los valores positivos se muestran en `text-emerald-700`, negativos en `text-red-600`
|
|
||||||
- `ComparisonTable` (`src/features/report/ComparisonTable.tsx` — nuevo):
|
|
||||||
- Tabla actividad por actividad: costo actual | costo automatizado | ahorro | % ahorro
|
|
||||||
- Ícono Bot ámbar en la primera columna para actividades marcadas `automatable`
|
|
||||||
- Ordenable por ahorro (↕) — default descendente
|
|
||||||
- Usa `Set<bpmnElementId>` para el lookup de `automatable` en O(1)
|
|
||||||
- `CumulativeSavingsChart` (`src/features/report/CumulativeSavingsChart.tsx` — nuevo):
|
|
||||||
- Gráfico de línea + área (ECharts): X=años 0..N, Y=ahorro neto acumulado
|
|
||||||
- Línea de break-even en y=0 siempre visible
|
|
||||||
- Marker vertical dashed verde en `x=paybackYears` (solo si finite y dentro del horizonte)
|
|
||||||
- Tooltip muestra valor del año exacto con color según signo
|
|
||||||
- `TopSavingsChart` (`src/features/report/TopSavingsChart.tsx` — nuevo):
|
|
||||||
- Top 5 actividades por ahorro positivo — barras horizontales verdes
|
|
||||||
- Excluye actividades con ahorro ≤ 0
|
|
||||||
- Altura dinámica: `max(180, n * 48)` px — no hay espacio vacío si hay menos de 5 actividades
|
|
||||||
- `TransparencySection` (`src/features/report/TransparencySection.tsx` — nuevo):
|
|
||||||
- Condicional — solo se renderiza si hay issues metodológicos detectados
|
|
||||||
- **Issue 1**: actividades con `automatable=true` pero `automatedCostFixed=0` y `automatedTimeMinutes=0` → el ahorro puede estar inflado. Lista los nombres de cada actividad
|
|
||||||
- **Issue 2**: `automationInvestment=0` → payback "inmediato" e ROI "infinito" no reflejan la realidad
|
|
||||||
- Fondo amber-50, borde amber-200 — consistente con el helper del ActivityPanel
|
|
||||||
|
|
||||||
**Por qué ROI inline y no en `useReportData`**:
|
|
||||||
El `calculateRoi` toma `simulation.result.totalCost` y `simulation.resultAutomated.totalCost` — valores ya disponibles en el componente. Moverlo al hook agregaría un `useEffect` extra sin beneficio. Se mantiene en el componente como `useMemo`.
|
|
||||||
|
|
||||||
**Tests nuevos** (17 tests):
|
|
||||||
|
|
||||||
- `tests/features/workspace/ActivityPanel.test.tsx`: 6 casos — toggle, helper ámbar con `aria-hidden`, dispatch de `updateActivity`
|
|
||||||
- `tests/features/workspace/GlobalSettingsPanel.test.tsx`: 7 casos — defaults, edición de campos, dispatch de `updateProcess`
|
|
||||||
- `tests/features/workspace/BpmnCanvas.test.tsx`: 4 casos — overlay add/remove, posición `left=width-12`, HTML con `#fffbeb` y `#f59e0b`
|
|
||||||
|
|
||||||
**Patrón de mocks para tests de componentes** (documentado para referencia futura):
|
|
||||||
|
|
||||||
- Los mocks del store con `vi.hoisted()` devuelven referencias estables
|
|
||||||
- Sin `vi.hoisted`: cada llamada a `useProcessStore()` devuelve un nuevo objeto → el `useEffect([activities])` de ActivityPanel dispara en loop infinito → "Maximum update depth exceeded"
|
|
||||||
- `simulation-store.ts` importa `process-store.ts` al nivel de módulo: los mocks deben ser compatibles o el store no carga
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [0.1.0-mvp] — MVP Fase 0 — 2026-05-13
|
|
||||||
|
|
||||||
### Contexto
|
|
||||||
|
|
||||||
Primera entrega funcional. Construida en una sesión de trabajo.
|
|
||||||
Objetivo: demostrar en ≤15 minutos que se puede transformar un BPMN en un reporte de costos.
|
|
||||||
|
|
||||||
### Funcionalidades
|
|
||||||
|
|
||||||
- Importar BPMN 2.0 por drag & drop o desde procesos de ejemplo
|
|
||||||
- Renderizar diagrama en canvas read-only (bpmn-js Viewer)
|
|
||||||
- Configurar actividades: costo fijo, tiempo, recursos asignados con utilización
|
|
||||||
- CRUD de recursos (rol/persona/sistema/equipo/insumo + costo/hora)
|
|
||||||
- Configuración global: moneda, overhead %, nombre proceso, cliente
|
|
||||||
- Motor de simulación determinístico con propagación de probabilidades por gateways XOR, AND, inclusivo
|
|
||||||
- Detección de loops (back-edges via DFS): se advierte pero no bloquea
|
|
||||||
- Reporte visual: mapa de calor en dos modos (relativo/absoluto), KPIs, top actividades, composición, costo por recurso
|
|
||||||
- Export PDF: 3 páginas (header+KPIs+heatmap, análisis, tabla detalle), con jsPDF-autotable
|
|
||||||
- Export CSV: con BOM UTF-8 y metadatos del proceso
|
|
||||||
- Persistencia en IndexedDB (Dexie v1)
|
|
||||||
- Deploy estático en Cloudflare Pages
|
|
||||||
|
|
||||||
### Decisiones de diseño relevantes para el futuro
|
|
||||||
|
|
||||||
**Parser BPMN multi-pool** (`bpmn-parser.ts`):
|
|
||||||
|
|
||||||
- Itera todos los `<bpmn:process>` hijos directos de `<definitions>` (no solo el primero)
|
|
||||||
- Fix crítico: el parser original usaba `querySelector` que retorna solo el primero → todos los pools de una colaboración excepto el primero eran ignorados
|
|
||||||
- Pools de soporte (SAP, bandeja manual) pueden no tener StartEvent propio — la validación exige al menos uno entre todos los pools
|
|
||||||
|
|
||||||
**Heatmap sobre bpmn-js** (`HeatmapCanvas.tsx`):
|
|
||||||
|
|
||||||
- bpmn-js rechaza la promesa de `importXML` pero el diagrama se renderiza parcialmente → usar `eventBus.on('import.done', ...)` en lugar de `.then()`
|
|
||||||
- `element.style.fill = color` (NO `setAttribute('fill', color)`): bpmn-js aplica CSS que tiene mayor especificidad que atributos SVG
|
|
||||||
- `captureImage()` espera 500ms después de importar para que la transición CSS `fill 0.35s ease` complete antes de la captura con html2canvas
|
|
||||||
|
|
||||||
**Validación XOR en WorkspacePage** (`WorkspacePage.tsx`):
|
|
||||||
|
|
||||||
- `hasLoadedOnce` ref: evita que el redirect "proceso no encontrado" dispare en el render inicial donde `isLoading=false` y `currentProcess=null` (antes del primer efecto de carga)
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
268 tests unitarios + integración al cierre de MVP.
|
|
||||||
5 tests E2E con Playwright (import → simulate → export PDF/CSV × 3 BPMNs + acentos + gradiente de color).
|
|
||||||
El test de gradiente verifica con `sharp` que el heatmap del PDF contiene píxeles verdes, ámbar y rojos — evita regresiones en la lógica de coloreo.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Convenciones de este changelog
|
|
||||||
|
|
||||||
- **Decisiones de diseño**: se documenta el *por qué*, no solo el *qué*
|
|
||||||
- **Gotchas**: comportamientos contrarios a la intuición se marcan explícitamente
|
|
||||||
- **Edge cases**: si un edge case motivó un test específico, se menciona
|
|
||||||
- **Retrocompatibilidad**: cuando un campo es opcional por retrocompat, se indica
|
|
||||||
- Las entradas son para humanos que modificarán el código, no para usuarios finales
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "process-cost-platform",
|
"name": "process-cost-platform",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
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
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { APP_VERSION } from '@/lib/version'
|
||||||
|
|
||||||
export function AppFooter() {
|
export function AppFooter() {
|
||||||
return (
|
return (
|
||||||
<footer className="mt-12 py-4 border-t border-slate-100 text-center">
|
<footer className="mt-12 py-4 border-t border-slate-100 text-center">
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">
|
||||||
InQ ROI · Powered by InQuality · v0.1.0 · Hecho desde Paraguay 🇵🇾
|
InQ ROI · Powered by InQuality · v{APP_VERSION} · Hecho desde Paraguay 🇵🇾
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
|
|||||||
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}</>
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ export const ProcessSchema = z.object({
|
|||||||
automationInvestment: z.number().min(0).default(0),
|
automationInvestment: z.number().min(0).default(0),
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
updatedAt: z.number(),
|
updatedAt: z.number(),
|
||||||
|
areaId: z.string().uuid().nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type ResourceInput = z.infer<typeof ResourceSchema>
|
export type ResourceInput = z.infer<typeof ResourceSchema>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface Process {
|
|||||||
updatedBy: string // 🆕 Sprint 4 — UUID del usuario que hizo el último update
|
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)
|
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)
|
orgName?: string | null // 🆕 Sprint 7 — nombre de la org (poblado por getById via JOIN, no se persiste)
|
||||||
|
areaId: string | null // 🆕 Sprint 8 — área de la org (null = sin área)
|
||||||
|
areaName?: string | null // 🆕 Sprint 8 — poblado via JOIN en getById
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ActivityType = 'task' | 'subprocess'
|
export type ActivityType = 'task' | 'subprocess'
|
||||||
@@ -89,6 +91,14 @@ export interface ProcessGroup {
|
|||||||
updatedAt: number // timestamp Unix ms
|
updatedAt: number // timestamp Unix ms
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Area {
|
||||||
|
id: string
|
||||||
|
orgId: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
createdAt: number // timestamp Unix ms
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
id: 'singleton' // siempre un único registro por instalación
|
id: 'singleton' // siempre un único registro por instalación
|
||||||
groupLabel: string // etiqueta configurable para el nivel de agrupación. Default: 'Cliente'
|
groupLabel: string // etiqueta configurable para el nivel de agrupación. Default: 'Cliente'
|
||||||
|
|||||||
@@ -1,47 +1,28 @@
|
|||||||
import { useEffect } from 'react'
|
import { Outlet, Link, useRouterState } from '@tanstack/react-router'
|
||||||
import { Outlet, Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
|
||||||
import { Loader2 } from 'lucide-react'
|
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
|
||||||
import { AppHeader } from '@/components/AppHeader'
|
import { AppHeader } from '@/components/AppHeader'
|
||||||
|
import { RequireRole } from '@/components/RequireRole'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
const { user, loading } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loading) return
|
|
||||||
if (!user || user.platformRole !== 'platform_admin') {
|
|
||||||
void navigate({ to: '/' })
|
|
||||||
}
|
|
||||||
}, [user, loading, navigate])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user || user.platformRole !== 'platform_admin') return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-slate-50">
|
<RequireRole roles={['platform_admin']}>
|
||||||
<AppHeader />
|
<div className="min-h-screen flex flex-col bg-slate-50">
|
||||||
<div className="flex-1 max-w-6xl mx-auto w-full px-6 py-8">
|
<AppHeader />
|
||||||
<nav className="flex gap-1 border-b border-slate-200 mb-6">
|
<div className="flex-1 max-w-6xl mx-auto w-full px-6 py-8">
|
||||||
<AdminNavTab to="/admin/organizations" active={pathname.startsWith('/admin/organizations')}>
|
<nav className="flex gap-1 border-b border-slate-200 mb-6">
|
||||||
Organizaciones
|
<AdminNavTab to="/admin/organizations" active={pathname.startsWith('/admin/organizations')}>
|
||||||
</AdminNavTab>
|
Organizaciones
|
||||||
<AdminNavTab to="/admin/users" active={pathname.startsWith('/admin/users')}>
|
</AdminNavTab>
|
||||||
Usuarios
|
<AdminNavTab to="/admin/users" active={pathname.startsWith('/admin/users')}>
|
||||||
</AdminNavTab>
|
Usuarios
|
||||||
</nav>
|
</AdminNavTab>
|
||||||
<Outlet />
|
</nav>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</RequireRole>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams, useNavigate, Link } from '@tanstack/react-router'
|
import { useParams, useNavigate, Link } from '@tanstack/react-router'
|
||||||
import { ArrowLeft, FileText, Users, FolderX, UserX, GitBranch } from 'lucide-react'
|
import { ArrowLeft, FileText, Users, FolderX, UserX, GitBranch, FolderOpen, Plus, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
@@ -20,14 +20,25 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { RoleBadge } from './RoleBadge'
|
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 {
|
interface OrgProcess {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
client: string | null
|
client: string | null
|
||||||
|
area_id: string | null
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AreaWithCount {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
processCount: number
|
||||||
|
}
|
||||||
|
|
||||||
interface OrgUser {
|
interface OrgUser {
|
||||||
id: string
|
id: string
|
||||||
name: string | null
|
name: string | null
|
||||||
@@ -66,16 +77,53 @@ export function OrganizationDetailPage() {
|
|||||||
const [assigning, setAssigning] = useState(false)
|
const [assigning, setAssigning] = useState(false)
|
||||||
const [assignError, setAssignError] = useState<string | null>(null)
|
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 fetchDetail = async () => {
|
||||||
const [orgRes, procRes, userRes] = await Promise.all([
|
const [orgRes, procRes, userRes] = await Promise.all([
|
||||||
supabase.from('organizations').select('name').eq('id', orgId).single(),
|
supabase.from('organizations').select('name').eq('id', orgId).single(),
|
||||||
supabase.from('processes').select('id, name, client, updated_at').eq('org_id', orgId).order('updated_at', { ascending: false }),
|
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'),
|
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)
|
if (orgRes.data) setOrgName((orgRes.data as { name: string }).name)
|
||||||
setProcesses((procRes.data as OrgProcess[] | null) ?? [])
|
const loadedProcesses = (procRes.data as OrgProcess[] | null) ?? []
|
||||||
|
setProcesses(loadedProcesses)
|
||||||
setUsers((userRes.data as OrgUser[] | null) ?? [])
|
setUsers((userRes.data as OrgUser[] | null) ?? [])
|
||||||
setLoading(false)
|
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])
|
useEffect(() => { void fetchDetail() }, [orgId])
|
||||||
@@ -114,6 +162,53 @@ export function OrganizationDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -148,6 +243,10 @@ export function OrganizationDetailPage() {
|
|||||||
<Users className="h-4 w-4 mr-1.5" />
|
<Users className="h-4 w-4 mr-1.5" />
|
||||||
Usuarios ({users.length})
|
Usuarios ({users.length})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="areas">
|
||||||
|
<FolderOpen className="h-4 w-4 mr-1.5" />
|
||||||
|
Áreas ({areas.length})
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* ─── Tab: Procesos ─────────────────────────────────────────────── */}
|
{/* ─── Tab: Procesos ─────────────────────────────────────────────── */}
|
||||||
@@ -240,6 +339,105 @@ export function OrganizationDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
|
|
||||||
{/* ─── Sheet: Asignar proceso ─────────────────────────────────────── */}
|
{/* ─── Sheet: Asignar proceso ─────────────────────────────────────── */}
|
||||||
@@ -277,6 +475,40 @@ export function OrganizationDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
interface OrgRow {
|
interface OrgRow {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
is_provider: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
processes: [{ count: number }]
|
processes: [{ count: number }]
|
||||||
users: [{ count: number }]
|
users: [{ count: number }]
|
||||||
@@ -41,10 +42,16 @@ export function OrganizationsPage() {
|
|||||||
const fetchOrgs = async () => {
|
const fetchOrgs = async () => {
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('organizations')
|
.from('organizations')
|
||||||
.select('id, name, created_at, processes:processes(count), users:users(count)')
|
.select('id, name, is_provider, created_at, processes:processes(count), users:users(count)')
|
||||||
.eq('is_provider', false)
|
|
||||||
.order('name')
|
.order('name')
|
||||||
setOrgs((data as OrgRow[] | null) ?? [])
|
|
||||||
|
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)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +91,7 @@ export function OrganizationsPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<h2 className="text-lg font-semibold text-slate-900">Organizaciones clientes</h2>
|
<h2 className="text-lg font-semibold text-slate-900">Organizaciones</h2>
|
||||||
<Button size="sm" onClick={openCreate}>
|
<Button size="sm" onClick={openCreate}>
|
||||||
<Plus className="h-4 w-4 mr-1.5" />
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
Nueva organización
|
Nueva organización
|
||||||
@@ -113,7 +120,17 @@ export function OrganizationsPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{orgs.map((org) => (
|
{orgs.map((org) => (
|
||||||
<tr key={org.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50 transition-colors">
|
<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">{org.name}</td>
|
<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">
|
<td className="py-3 px-4 text-right text-slate-600 tabular-nums">
|
||||||
{org.processes[0]?.count ?? 0}
|
{org.processes[0]?.count ?? 0}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Limitaciones conocidas documentadas:
|
* Limitaciones conocidas:
|
||||||
* 1. email no está en public.users (vive en auth.users, requiere service_role) → solo se muestra name.
|
* - "Reactivar" restaura siempre a 'client_editor' — no se guarda el rol previo a la suspensión.
|
||||||
* 2. "Eliminar" llama revoke-user que baja platform_role a 'guest'. Eliminación real requiere Edge Function futura.
|
|
||||||
* 3. "Reactivar" restaura siempre a 'client_editor' — no se guarda el rol previo a la suspensión.
|
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
import { UserX, UserPlus, MoreVertical } from 'lucide-react'
|
import { UserX, UserPlus, MoreVertical, Trash2 } from 'lucide-react'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -34,6 +32,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { RoleBadge } from './RoleBadge'
|
import { RoleBadge } from './RoleBadge'
|
||||||
|
|
||||||
interface AdminUser {
|
interface AdminUser {
|
||||||
@@ -44,6 +43,7 @@ interface AdminUser {
|
|||||||
org_id: string | null
|
org_id: string | null
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
organization: { name: string; is_provider: boolean } | null
|
organization: { name: string; is_provider: boolean } | null
|
||||||
|
email?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrgOption {
|
interface OrgOption {
|
||||||
@@ -116,6 +116,7 @@ const CHANGE_ROLES = ['platform_admin', 'member', 'client_editor', 'client_viewe
|
|||||||
|
|
||||||
export function UsersPage() {
|
export function UsersPage() {
|
||||||
const { user: adminUser } = useAuth()
|
const { user: adminUser } = useAuth()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const [users, setUsers] = useState<AdminUser[]>([])
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
const [orgs, setOrgs] = useState<OrgOption[]>([])
|
const [orgs, setOrgs] = useState<OrgOption[]>([])
|
||||||
@@ -155,8 +156,25 @@ export function UsersPage() {
|
|||||||
const finalQuery = filterOrgId !== 'all'
|
const finalQuery = filterOrgId !== 'all'
|
||||||
? query.eq('org_id', filterOrgId)
|
? query.eq('org_id', filterOrgId)
|
||||||
: query
|
: query
|
||||||
const { data } = await finalQuery
|
|
||||||
setUsers((data as AdminUser[] | null) ?? [])
|
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)
|
setLoadingUsers(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,16 +278,24 @@ export function UsersPage() {
|
|||||||
void fetchUsers()
|
void fetchUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Delete (también usa revoke-user — baja a guest, eliminación real futura) ─
|
// ── Hard delete vía Edge Function delete-user ─────────────────────────────
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
const { error } = await supabase.functions.invoke('revoke-user', {
|
const { error } = await supabase.functions.invoke('delete-user', {
|
||||||
body: { userId: deleteTarget.id },
|
body: { userId: deleteTarget.id },
|
||||||
})
|
})
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error al eliminar usuario:', 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)
|
setDeleteTarget(null)
|
||||||
setDeleting(false)
|
setDeleting(false)
|
||||||
@@ -334,6 +360,7 @@ export function UsersPage() {
|
|||||||
<thead className="bg-slate-50 border-b border-slate-200">
|
<thead className="bg-slate-50 border-b border-slate-200">
|
||||||
<tr>
|
<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">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">Rol</th>
|
||||||
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Organización</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" />
|
<th className="py-2.5 px-4" />
|
||||||
@@ -363,6 +390,9 @@ export function UsersPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3 px-4 text-slate-500 text-xs font-mono">
|
||||||
|
{u.email ?? '—'}
|
||||||
|
</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<RoleBadge role={u.platform_role} />
|
<RoleBadge role={u.platform_role} />
|
||||||
</td>
|
</td>
|
||||||
@@ -403,7 +433,8 @@ export function UsersPage() {
|
|||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onClick={() => setDeleteTarget(u)}
|
onClick={() => setDeleteTarget(u)}
|
||||||
>
|
>
|
||||||
Eliminar
|
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||||
|
Eliminar permanentemente
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -537,13 +568,13 @@ export function UsersPage() {
|
|||||||
destructive
|
destructive
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ─── ConfirmDialog: Eliminar ─────────────────────────────────────── */}
|
{/* ─── ConfirmDialog: Eliminar permanentemente ────────────────────── */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||||
title={`¿Eliminar a ${deleteTarget?.name ?? 'este usuario'}?`}
|
title={`¿Eliminar permanentemente a ${deleteTarget?.name ?? 'este usuario'}?`}
|
||||||
description="El usuario quedará suspendido permanentemente. (Nota: la eliminación real de la cuenta estará disponible en una versión futura.)"
|
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"
|
confirmLabel="Eliminar permanentemente"
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
destructive
|
destructive
|
||||||
@@ -580,6 +611,7 @@ function TableSkeleton({ rows }: { rows: number }) {
|
|||||||
{Array.from({ length: rows }).map((_, i) => (
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
<div key={i} className="flex gap-4 px-4 py-3 bg-white items-center">
|
<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 flex-1" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
<Skeleton className="h-5 w-20 rounded" />
|
<Skeleton className="h-5 w-20 rounded" />
|
||||||
<Skeleton className="h-4 w-28" />
|
<Skeleton className="h-4 w-28" />
|
||||||
<Skeleton className="h-6 w-6 rounded" />
|
<Skeleton className="h-6 w-6 rounded" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
|
import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
@@ -13,6 +13,7 @@ import { useToast } from '@/components/ui/use-toast'
|
|||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { AppFooter } from '@/components/AppFooter'
|
import { AppFooter } from '@/components/AppFooter'
|
||||||
import { AppHeader } from '@/components/AppHeader'
|
import { AppHeader } from '@/components/AppHeader'
|
||||||
|
import { RequireRole } from '@/components/RequireRole'
|
||||||
|
|
||||||
const SAMPLE_PROCESSES = [
|
const SAMPLE_PROCESSES = [
|
||||||
{
|
{
|
||||||
@@ -43,15 +44,6 @@ export function ImportPage() {
|
|||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
const [loadingSampleId, setLoadingSampleId] = useState<string | null>(null)
|
const [loadingSampleId, setLoadingSampleId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Solo platform_admin y member pueden importar BPMN
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) return
|
|
||||||
const allowed = ['platform_admin', 'member']
|
|
||||||
if (!allowed.includes(user.platformRole)) {
|
|
||||||
void navigate({ to: '/' })
|
|
||||||
}
|
|
||||||
}, [user, navigate])
|
|
||||||
|
|
||||||
// groupId viene de la URL cuando el usuario importa desde dentro de un grupo
|
// groupId viene de la URL cuando el usuario importa desde dentro de un grupo
|
||||||
const search = useSearch({ from: '/import' })
|
const search = useSearch({ from: '/import' })
|
||||||
const targetGroupId = (search as { groupId?: string }).groupId ?? null
|
const targetGroupId = (search as { groupId?: string }).groupId ?? null
|
||||||
@@ -91,7 +83,8 @@ export function ImportPage() {
|
|||||||
tags: [],
|
tags: [],
|
||||||
ownerId: user!.id,
|
ownerId: user!.id,
|
||||||
updatedBy: user!.id,
|
updatedBy: user!.id,
|
||||||
orgId: null,
|
orgId: user!.orgId ?? null,
|
||||||
|
areaId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const activityElements = extractActivityElements(graph)
|
const activityElements = extractActivityElements(graph)
|
||||||
@@ -166,7 +159,7 @@ export function ImportPage() {
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [navigate, toast])
|
}, [navigate, toast, user])
|
||||||
|
|
||||||
const onDrop = useCallback((e: React.DragEvent) => {
|
const onDrop = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -200,10 +193,8 @@ export function ImportPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
|
|
||||||
if (isClientRole) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<RequireRole roles={['platform_admin', 'member', 'client_editor']}>
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-6">
|
<div className="flex-1 flex flex-col items-center justify-center p-6">
|
||||||
@@ -211,7 +202,7 @@ export function ImportPage() {
|
|||||||
<div className="text-center mb-10">
|
<div className="text-center mb-10">
|
||||||
<div className="inline-flex items-center gap-2 text-primary text-sm font-medium mb-4 bg-primary/10 px-3 py-1 rounded-full">
|
<div className="inline-flex items-center gap-2 text-primary text-sm font-medium mb-4 bg-primary/10 px-3 py-1 rounded-full">
|
||||||
<FlaskConical className="h-3.5 w-3.5" />
|
<FlaskConical className="h-3.5 w-3.5" />
|
||||||
Process Cost Platform · MVP Fase 0
|
InQ ROI
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl font-bold text-slate-900 mb-3">
|
<h1 className="text-4xl font-bold text-slate-900 mb-3">
|
||||||
Cuantificá el costo operativo<br />de tus procesos
|
Cuantificá el costo operativo<br />de tus procesos
|
||||||
@@ -275,8 +266,8 @@ export function ImportPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Procesos de ejemplo — visibles directamente */}
|
{/* Procesos de ejemplo — solo para InQuality */}
|
||||||
<div className="mt-8 w-full max-w-lg">
|
{user?.platformRole !== 'client_editor' && <div className="mt-8 w-full max-w-lg">
|
||||||
<p className="text-xs text-slate-400 uppercase tracking-wide font-medium text-center mb-3">
|
<p className="text-xs text-slate-400 uppercase tracking-wide font-medium text-center mb-3">
|
||||||
O cargá un proceso de ejemplo con un click
|
O cargá un proceso de ejemplo con un click
|
||||||
</p>
|
</p>
|
||||||
@@ -310,14 +301,11 @@ export function ImportPage() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
<p className="mt-8 text-xs text-slate-300">
|
|
||||||
100% en tu navegador · Sin backend · Sin registro · Datos guardados localmente
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</RequireRole>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import { useNavigate, useRouterState } from '@tanstack/react-router'
|
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
Upload, Search, Plus, Building2, FolderX, GitBranch,
|
Upload, Search, Building2, GitBranch,
|
||||||
Clock, TrendingUp, BarChart2, FileX2, X, Settings,
|
Clock, TrendingUp, BarChart2, FileX2, X, Settings,
|
||||||
MoreVertical, Trash2, Share2, Lock, User,
|
MoreVertical, Trash2, Share2, Lock, User,
|
||||||
|
ChevronDown, FolderOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useLibraryStore } from '@/store/library-store'
|
import { useLibraryStore } from '@/store/library-store'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { supabaseProcessRepo, type ProcessWithUserNames } from '@/persistence/supabase/process-repo'
|
import { supabaseProcessRepo, type ProcessWithUserNames } from '@/persistence/supabase/process-repo'
|
||||||
import { formatCurrency, formatSimulationTimestamp } from '@/lib/format'
|
import { formatCurrency, formatSimulationTimestamp } from '@/lib/format'
|
||||||
import type { Process, ProcessGroup, Simulation } from '@/domain/types'
|
import type { Process, Simulation } from '@/domain/types'
|
||||||
import { AppHeader } from '@/components/AppHeader'
|
import { AppHeader } from '@/components/AppHeader'
|
||||||
|
import { AppFooter } from '@/components/AppFooter'
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -266,129 +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 ────────────────────────────────────────────────────────────────
|
// ─── Home View ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function HomeView({
|
function HomeView({
|
||||||
onNavigateToGroup,
|
|
||||||
onImport,
|
onImport,
|
||||||
ownerFilter,
|
ownerFilter,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
currentUserName,
|
currentUserName,
|
||||||
onShared,
|
onShared,
|
||||||
}: {
|
}: {
|
||||||
onNavigateToGroup: (groupId: string | null) => void
|
onImport: () => void
|
||||||
onImport: (groupId?: string) => void
|
|
||||||
ownerFilter: 'all' | 'mine'
|
ownerFilter: 'all' | 'mine'
|
||||||
currentUserId: string
|
currentUserId: string
|
||||||
currentUserName: string
|
currentUserName: string
|
||||||
onShared: () => void
|
onShared: () => void
|
||||||
}) {
|
}) {
|
||||||
const { groups, processes, settings, createGroup, isLoading, error, load } = useLibraryStore()
|
const { groups, processes, isLoading, error, load } = useLibraryStore()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [activeTags, setActiveTags] = useState<string[]>([])
|
const [activeTags, setActiveTags] = useState<string[]>([])
|
||||||
@@ -398,10 +293,46 @@ function HomeView({
|
|||||||
: processes
|
: processes
|
||||||
|
|
||||||
const cloud = tagCloudData(visibleProcesses)
|
const cloud = tagCloudData(visibleProcesses)
|
||||||
const unclassifiedProcesses = visibleProcesses.filter((p) => p.groupId == null)
|
|
||||||
|
|
||||||
const isSearching = search.trim().length > 0 || activeTags.length > 0
|
const isSearching = search.trim().length > 0 || activeTags.length > 0
|
||||||
|
|
||||||
|
const [collapsedOrgs, setCollapsedOrgs] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const orgGroups = useMemo(() => {
|
||||||
|
const byOrg = new Map<string, { orgId: string; orgName: string; processes: ProcessWithUserNames[] }>()
|
||||||
|
for (const p of visibleProcesses) {
|
||||||
|
const key = p.orgId ?? '__sin_org'
|
||||||
|
const orgName = p.orgName ?? 'Sin organización'
|
||||||
|
if (!byOrg.has(key)) byOrg.set(key, { orgId: key, orgName, processes: [] })
|
||||||
|
byOrg.get(key)!.processes.push(p)
|
||||||
|
}
|
||||||
|
return [...byOrg.values()].sort((a, b) => a.orgName.localeCompare(b.orgName))
|
||||||
|
}, [visibleProcesses])
|
||||||
|
|
||||||
|
function getAreaGroups(orgProcesses: ProcessWithUserNames[]) {
|
||||||
|
const byArea = new Map<string, { areaId: string; areaName: string; processes: ProcessWithUserNames[] }>()
|
||||||
|
for (const p of orgProcesses) {
|
||||||
|
const key = p.areaId ?? '__sin_area'
|
||||||
|
const areaName = p.areaName ?? 'Sin área'
|
||||||
|
if (!byArea.has(key)) byArea.set(key, { areaId: key, areaName, processes: [] })
|
||||||
|
byArea.get(key)!.processes.push(p)
|
||||||
|
}
|
||||||
|
return [...byArea.values()].sort((a, b) => {
|
||||||
|
if (a.areaId === '__sin_area') return 1
|
||||||
|
if (b.areaId === '__sin_area') return -1
|
||||||
|
return a.areaName.localeCompare(b.areaName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOrg(orgId: string) {
|
||||||
|
setCollapsedOrgs(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(orgId)) next.delete(orgId)
|
||||||
|
else next.add(orgId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const filteredProcesses = isSearching
|
const filteredProcesses = isSearching
|
||||||
? visibleProcesses.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
|
? visibleProcesses.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
|
||||||
: []
|
: []
|
||||||
@@ -452,6 +383,25 @@ function HomeView({
|
|||||||
// Empty state: sin grupos ni procesos (ya terminó de cargar)
|
// Empty state: sin grupos ni procesos (ya terminó de cargar)
|
||||||
if (groups.length === 0 && processes.length === 0) {
|
if (groups.length === 0 && processes.length === 0) {
|
||||||
if (isClientRole) {
|
if (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 (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||||
<Building2 className="h-16 w-16 text-muted-foreground/30" />
|
<Building2 className="h-16 w-16 text-muted-foreground/30" />
|
||||||
@@ -521,26 +471,23 @@ function HomeView({
|
|||||||
{filteredProcesses.length === 0 && (
|
{filteredProcesses.length === 0 && (
|
||||||
<p className="text-[13px] text-muted-foreground py-4 text-center">Sin resultados para esa búsqueda.</p>
|
<p className="text-[13px] text-muted-foreground py-4 text-center">Sin resultados para esa búsqueda.</p>
|
||||||
)}
|
)}
|
||||||
{filteredProcesses.map((p) => {
|
{filteredProcesses.map((p) => (
|
||||||
const group = groups.find((g) => g.id === p.groupId)
|
<ProcessCardWithSim
|
||||||
return (
|
key={p.id}
|
||||||
<ProcessCardWithSim
|
process={p}
|
||||||
key={p.id}
|
showGroupChip
|
||||||
process={p}
|
groupName={p.areaName ?? p.orgName ?? undefined}
|
||||||
showGroupChip
|
currentUserId={currentUserId}
|
||||||
groupName={group?.name}
|
currentUserName={currentUserName}
|
||||||
currentUserId={currentUserId}
|
onShared={onShared}
|
||||||
currentUserName={currentUserName}
|
/>
|
||||||
onShared={onShared}
|
))}
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Vista normal — grupos (solo roles internos) */}
|
{/* Vista normal — org → área (solo roles internos) */}
|
||||||
{!isSearching && !isClientRole && (
|
{!isSearching && !isClientRole && (
|
||||||
<>
|
<div className="space-y-3" data-testid="groups-header">
|
||||||
{/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */}
|
{/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */}
|
||||||
{cloud.length >= 3 && (
|
{cloud.length >= 3 && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -574,59 +521,89 @@ function HomeView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header sección grupos */}
|
{/* Grupos org → área */}
|
||||||
<div className="flex items-center gap-2" data-testid="groups-header">
|
{orgGroups.map(({ orgId, orgName, processes: orgProcesses }) => {
|
||||||
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
const isCollapsed = collapsedOrgs.has(orgId)
|
||||||
{settings.groupLabel}s
|
const areaGroups = getAreaGroups(orgProcesses)
|
||||||
</span>
|
|
||||||
<span className="text-[10px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground">
|
|
||||||
{groups.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid de grupos */}
|
return (
|
||||||
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
|
<div key={orgId} id={`org-section-${orgId}`} className="border border-border rounded-xl overflow-hidden">
|
||||||
{groups.map((group) => {
|
{/* Header de org — colapsable */}
|
||||||
const groupProcesses = visibleProcesses.filter((p) => p.groupId === group.id)
|
<button
|
||||||
const lastUpdated = groupProcesses.length > 0
|
onClick={() => toggleOrg(orgId)}
|
||||||
? Math.max(...groupProcesses.map((p) => p.updatedAt))
|
className="w-full flex items-center justify-between px-4 py-3 bg-secondary/40 hover:bg-secondary/60 transition-colors"
|
||||||
: null
|
>
|
||||||
return (
|
<div className="flex items-center gap-2">
|
||||||
<div key={group.id} id={`group-${group.id}`}>
|
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||||
<GroupCard
|
<span className="text-[13px] font-medium">{orgName}</span>
|
||||||
group={group}
|
<span className="text-[11px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground">
|
||||||
processCount={groupProcesses.length}
|
{orgProcesses.length}
|
||||||
lastUpdated={lastUpdated}
|
</span>
|
||||||
onClick={() => onNavigateToGroup(group.id)}
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
className="h-4 w-4 text-muted-foreground transition-transform duration-200"
|
||||||
|
style={{ transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
)
|
|
||||||
})}
|
|
||||||
<NewGroupCard
|
|
||||||
label={settings.groupLabel}
|
|
||||||
onCreate={(name) => createGroup(name, user!.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sin clasificar */}
|
{/* Contenido — animado */}
|
||||||
{unclassifiedProcesses.length > 0 && (
|
<div
|
||||||
<button
|
className="transition-all duration-200 overflow-hidden"
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all hover:border-border/80"
|
style={{ maxHeight: isCollapsed ? 0 : '9999px', opacity: isCollapsed ? 0 : 1 }}
|
||||||
style={{ border: '0.5px dashed hsl(var(--border))', background: 'hsl(var(--card))', borderRadius: '0.5rem' }}
|
>
|
||||||
onClick={() => onNavigateToGroup(null)}
|
<div className="p-3 space-y-3">
|
||||||
>
|
{areaGroups.map(({ areaId, areaName, processes: areaProcesses }) => (
|
||||||
<FolderX className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
<div key={areaId}>
|
||||||
<div className="flex-1 min-w-0">
|
{/* Sub-header de área: visible si hay >1 área o si el área tiene nombre */}
|
||||||
<span className="text-[13px] font-medium text-secondary-foreground">
|
{(areaGroups.length > 1 || areaId !== '__sin_area') && (
|
||||||
Procesos sin {settings.groupLabel.toLowerCase()} asignado
|
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-2 flex items-center gap-1.5">
|
||||||
</span>
|
<FolderOpen className="h-3 w-3" />
|
||||||
|
{areaName}
|
||||||
|
<span className="normal-case tracking-normal font-normal">
|
||||||
|
· {areaProcesses.length}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{areaProcesses.map((p) => (
|
||||||
|
<ProcessCardWithSim
|
||||||
|
key={p.id}
|
||||||
|
process={p}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
currentUserName={currentUserName}
|
||||||
|
onShared={onShared}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-muted-foreground flex-shrink-0">
|
)
|
||||||
{unclassifiedProcesses.length} proceso{unclassifiedProcesses.length !== 1 ? 's' : ''}
|
})}
|
||||||
</span>
|
|
||||||
</button>
|
{/* Empty state: admin sin procesos */}
|
||||||
|
{orgGroups.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
|
<Building2 className="h-12 w-12 text-muted-foreground/30" />
|
||||||
|
<p className="text-[13px] font-medium text-secondary-foreground">
|
||||||
|
Tu biblioteca está vacía
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground">
|
||||||
|
Importá tu primer BPMN para empezar
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => onImport()}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white"
|
||||||
|
style={{ background: '#F59845' }}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Importar BPMN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Vista cliente: lista plana de procesos (sin grupos, RLS garantiza que solo ven los suyos) */}
|
{/* Vista cliente: lista plana de procesos (sin grupos, RLS garantiza que solo ven los suyos) */}
|
||||||
@@ -773,39 +750,33 @@ function GroupView({
|
|||||||
|
|
||||||
export function LibraryPage() {
|
export function LibraryPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { load, processes, groups, settings, isLoading } = useLibraryStore()
|
const { load, processes, isLoading } = useLibraryStore()
|
||||||
const { user, isProfileLoaded } = useAuth()
|
const { user, isProfileLoaded } = useAuth()
|
||||||
const searchStr = useRouterState({ select: (s) => s.location.search })
|
|
||||||
const orgParam = new URLSearchParams(searchStr).get('org')
|
|
||||||
const [view, setView] = useState<'home' | 'group'>('home')
|
const [view, setView] = useState<'home' | 'group'>('home')
|
||||||
const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
|
const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
|
||||||
const [transitioning, setTransitioning] = useState(false)
|
const [transitioning, setTransitioning] = useState(false)
|
||||||
const [ownerFilter, setOwnerFilter] = useState<'all' | 'mine'>('all')
|
const [ownerFilter, setOwnerFilter] = useState<'all' | 'mine'>('all')
|
||||||
const { message: toastMessage, show: showToast } = useToast()
|
const { message: toastMessage, show: showToast } = useToast()
|
||||||
|
|
||||||
|
const location = useLocation()
|
||||||
|
const orgParam = useMemo(
|
||||||
|
() => new URLSearchParams(location.search).get('org') ?? undefined,
|
||||||
|
[location.search]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return // no fetchear hasta tener usuario confirmado (evita race condition con sesión Supabase)
|
if (!user) return // no fetchear hasta tener usuario confirmado (evita race condition con sesión Supabase)
|
||||||
load()
|
load()
|
||||||
}, [load, user?.id])
|
}, [load, user?.id])
|
||||||
|
|
||||||
// Cuando la biblioteca cargó con ?org=<uuid>, hacer scroll al grupo que contiene ese org
|
// Scroll a la sección del org si venimos del workspace
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!orgParam || isLoading || processes.length === 0) return
|
if (!orgParam || isLoading || processes.length === 0) return
|
||||||
const proc = processes.find((p) => p.orgId === orgParam)
|
|
||||||
if (!proc?.groupId) return
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
document.getElementById(`group-${proc.groupId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
const el = document.getElementById(`org-section-${orgParam}`)
|
||||||
|
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
})
|
})
|
||||||
}, [orgParam, isLoading, processes])
|
}, [orgParam, isLoading, processes.length])
|
||||||
|
|
||||||
function navigateToGroup(groupId: string | null) {
|
|
||||||
setTransitioning(true)
|
|
||||||
setTimeout(() => {
|
|
||||||
setActiveGroupId(groupId)
|
|
||||||
setView('group')
|
|
||||||
setTransitioning(false)
|
|
||||||
}, 150)
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateHome() {
|
function navigateHome() {
|
||||||
setTransitioning(true)
|
setTransitioning(true)
|
||||||
@@ -816,12 +787,8 @@ export function LibraryPage() {
|
|||||||
}, 150)
|
}, 150)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleImport(groupId?: string) {
|
function handleImport() {
|
||||||
if (groupId) {
|
navigate({ to: '/import' })
|
||||||
navigate({ to: '/import', search: { groupId } })
|
|
||||||
} else {
|
|
||||||
navigate({ to: '/import' })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Esperar a que syncProfileFromDB complete antes de renderizar UI rol-dependiente.
|
// Esperar a que syncProfileFromDB complete antes de renderizar UI rol-dependiente.
|
||||||
@@ -847,8 +814,8 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
const currentUserId = user.id
|
const currentUserId = user.id
|
||||||
const currentUserName = user.name
|
const currentUserName = user.name
|
||||||
const totalGroups = groups.length
|
|
||||||
const isClientRole = user.platformRole === 'client_editor' || user.platformRole === 'client_viewer'
|
const isClientRole = user.platformRole === 'client_editor' || user.platformRole === 'client_viewer'
|
||||||
|
const orgCount = new Set(processes.map(p => p.orgId).filter(Boolean)).size
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
@@ -859,7 +826,10 @@ export function LibraryPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-[18px] font-medium">Biblioteca de procesos</h1>
|
<h1 className="text-[18px] font-medium">Biblioteca de procesos</h1>
|
||||||
<p className="text-[12px] text-muted-foreground mt-0.5">
|
<p className="text-[12px] text-muted-foreground mt-0.5">
|
||||||
{processes.length} proceso{processes.length !== 1 ? 's' : ''} · {totalGroups} {settings.groupLabel.toLowerCase()}{totalGroups !== 1 ? 's' : ''}
|
{isClientRole
|
||||||
|
? `${processes.length} proceso${processes.length !== 1 ? 's' : ''}`
|
||||||
|
: `${processes.length} proceso${processes.length !== 1 ? 's' : ''} · ${orgCount} cliente${orgCount !== 1 ? 's' : ''}`
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -887,16 +857,18 @@ export function LibraryPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
|
||||||
{!isClientRole && (
|
{!isClientRole && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleImport(view === 'group' && activeGroupId ? activeGroupId : undefined)}
|
onClick={() => navigate({ to: '/settings' })}
|
||||||
|
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||||
|
title="Configuración"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{user.platformRole !== 'client_viewer' && (
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
|
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' }}
|
style={{ background: '#F59845' }}
|
||||||
>
|
>
|
||||||
@@ -914,7 +886,6 @@ export function LibraryPage() {
|
|||||||
>
|
>
|
||||||
{view === 'home' ? (
|
{view === 'home' ? (
|
||||||
<HomeView
|
<HomeView
|
||||||
onNavigateToGroup={navigateToGroup}
|
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
ownerFilter={ownerFilter}
|
ownerFilter={ownerFilter}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
@@ -932,6 +903,7 @@ export function LibraryPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<AppFooter />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toast message={toastMessage} />
|
<Toast message={toastMessage} />
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { useProcessStore } from '@/store/process-store'
|
import { useProcessStore } from '@/store/process-store'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { formatPercent } from '@/lib/format'
|
import { formatPercent } from '@/lib/format'
|
||||||
|
import { supabaseAreaRepo } from '@/persistence/supabase/area-repo'
|
||||||
|
import type { Area } from '@/domain/types'
|
||||||
|
|
||||||
const CURRENCIES = [
|
const CURRENCIES = [
|
||||||
{ code: 'USD', label: 'USD — Dólar estadounidense' },
|
{ code: 'USD', label: 'USD — Dólar estadounidense' },
|
||||||
@@ -28,7 +30,9 @@ export function GlobalSettingsPanel() {
|
|||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
|
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
|
||||||
const [localName, setLocalName] = useState('')
|
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 [localCurrency, setLocalCurrency] = useState('USD')
|
||||||
const [localOverhead, setLocalOverhead] = useState(20)
|
const [localOverhead, setLocalOverhead] = useState(20)
|
||||||
const [localFrequency, setLocalFrequency] = useState(1000)
|
const [localFrequency, setLocalFrequency] = useState(1000)
|
||||||
@@ -39,7 +43,18 @@ export function GlobalSettingsPanel() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentProcess) return
|
if (!currentProcess) return
|
||||||
setLocalName(currentProcess.name)
|
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)
|
setLocalCurrency(currentProcess.currency)
|
||||||
setLocalOverhead(Math.round(currentProcess.overheadPercentage * 100))
|
setLocalOverhead(Math.round(currentProcess.overheadPercentage * 100))
|
||||||
setLocalFrequency(currentProcess.annualFrequency)
|
setLocalFrequency(currentProcess.annualFrequency)
|
||||||
@@ -54,7 +69,7 @@ export function GlobalSettingsPanel() {
|
|||||||
const clampedHorizon = Math.min(10, Math.max(1, localHorizon))
|
const clampedHorizon = Math.min(10, Math.max(1, localHorizon))
|
||||||
await updateProcess({
|
await updateProcess({
|
||||||
name: localName.trim() || 'Proceso sin nombre',
|
name: localName.trim() || 'Proceso sin nombre',
|
||||||
clientName: localClient.trim(),
|
areaId: localAreaId === '__none__' ? null : localAreaId,
|
||||||
currency: localCurrency,
|
currency: localCurrency,
|
||||||
overheadPercentage: localOverhead / 100,
|
overheadPercentage: localOverhead / 100,
|
||||||
annualFrequency: Math.max(1, localFrequency),
|
annualFrequency: Math.max(1, localFrequency),
|
||||||
@@ -82,19 +97,35 @@ export function GlobalSettingsPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cliente */}
|
{/* Área */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-medium">Cliente</Label>
|
<Label className="text-xs font-medium">Área</Label>
|
||||||
{isClientRole ? (
|
{areasLoading ? (
|
||||||
<p className="text-sm text-slate-600 h-8 flex items-center px-1">{localClient || '—'}</p>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Select
|
||||||
id="client-name"
|
value={localAreaId}
|
||||||
value={localClient}
|
onValueChange={(v) => { setLocalAreaId(v); markDirty() }}
|
||||||
onChange={(e) => { setLocalClient(e.target.value); markDirty() }}
|
>
|
||||||
placeholder="ej. Empresa ABC"
|
<SelectTrigger className="h-8 text-xs">
|
||||||
className="h-8 text-sm"
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -192,8 +192,10 @@ export function WorkspacePage() {
|
|||||||
: currentProcess.orgName ?? 'Biblioteca'
|
: currentProcess.orgName ?? 'Biblioteca'
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
// Navega siempre a /library (sin ?org=) hasta que exista una vista filtrada por org
|
void navigate({
|
||||||
void navigate({ to: '/library' })
|
to: '/library',
|
||||||
|
search: { org: currentProcess?.orgId ?? undefined },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Label del tab "elemento" según lo seleccionado
|
// Label del tab "elemento" según lo seleccionado
|
||||||
@@ -249,38 +251,9 @@ export function WorkspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cliente — editable inline solo para roles internos */}
|
{/* Área — display de solo lectura (editable desde el panel de configuración) */}
|
||||||
{user?.platformRole === 'client_editor' ? (
|
{currentProcess.areaName && (
|
||||||
currentProcess.clientName && (
|
<p className="text-xs text-slate-400 truncate">{currentProcess.areaName}</p>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags — chips removibles + input inline */}
|
{/* Tags — chips removibles + input inline */}
|
||||||
|
|||||||
1
src/lib/version.ts
Normal file
1
src/lib/version.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const APP_VERSION = '0.8.0'
|
||||||
59
src/persistence/supabase/area-repo.ts
Normal file
59
src/persistence/supabase/area-repo.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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 = {
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
|
||||||
|
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>)
|
||||||
|
},
|
||||||
|
|
||||||
|
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>)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Los procesos con esta área pasan a area_id = null (ON DELETE SET NULL en migración 017)
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const { error } = await supabase.from('areas').delete().eq('id', id)
|
||||||
|
if (error) throw error
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ function toRow(process: Process, userId: string) {
|
|||||||
analysis_horizon_years: process.analysisHorizonYears,
|
analysis_horizon_years: process.analysisHorizonYears,
|
||||||
automation_investment: process.automationInvestment,
|
automation_investment: process.automationInvestment,
|
||||||
group_id: process.groupId,
|
group_id: process.groupId,
|
||||||
|
area_id: process.areaId,
|
||||||
|
org_id: process.orgId ?? null,
|
||||||
tags: process.tags,
|
tags: process.tags,
|
||||||
// owner_id se preserva del valor existente en el proceso; solo se inicializa con userId en creates
|
// owner_id se preserva del valor existente en el proceso; solo se inicializa con userId en creates
|
||||||
owner_id: process.ownerId || userId,
|
owner_id: process.ownerId || userId,
|
||||||
@@ -25,6 +27,8 @@ function toRow(process: Process, userId: string) {
|
|||||||
function fromRow(row: Record<string, unknown>): Process {
|
function fromRow(row: Record<string, unknown>): Process {
|
||||||
// org puede estar presente si el query hizo JOIN con organizations (getById lo incluye)
|
// org puede estar presente si el query hizo JOIN con organizations (getById lo incluye)
|
||||||
const org = row.org as { name: string } | null
|
const org = row.org as { name: string } | null
|
||||||
|
// area puede estar presente si el query hizo JOIN con areas (getById lo incluye)
|
||||||
|
const area = row.area as { name: string } | null
|
||||||
return {
|
return {
|
||||||
id: row.id as string,
|
id: row.id as string,
|
||||||
name: row.name as string,
|
name: row.name as string,
|
||||||
@@ -43,6 +47,8 @@ function fromRow(row: Record<string, unknown>): Process {
|
|||||||
updatedBy: (row.updated_by as string) ?? '',
|
updatedBy: (row.updated_by as string) ?? '',
|
||||||
orgId: (row.org_id as string | null) ?? null,
|
orgId: (row.org_id as string | null) ?? null,
|
||||||
orgName: org?.name ?? null,
|
orgName: org?.name ?? null,
|
||||||
|
areaId: (row.area_id as string | null) ?? null,
|
||||||
|
areaName: area?.name ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +97,7 @@ export const supabaseProcessRepo = {
|
|||||||
automation_investment: process.automationInvestment,
|
automation_investment: process.automationInvestment,
|
||||||
tags: process.tags,
|
tags: process.tags,
|
||||||
group_id: process.groupId,
|
group_id: process.groupId,
|
||||||
|
area_id: process.areaId,
|
||||||
updated_by: userId,
|
updated_by: userId,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
@@ -102,7 +109,7 @@ export const supabaseProcessRepo = {
|
|||||||
// JOIN con organizations para obtener orgName en una sola query (evita fetch separado y race conditions)
|
// JOIN con organizations para obtener orgName en una sola query (evita fetch separado y race conditions)
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('processes')
|
.from('processes')
|
||||||
.select('*, org:organizations!org_id(name)')
|
.select('*, org:organizations!org_id(name), area:areas!area_id(name)')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.single()
|
.single()
|
||||||
if (error) return undefined
|
if (error) return undefined
|
||||||
@@ -115,7 +122,7 @@ export const supabaseProcessRepo = {
|
|||||||
async getAll(): Promise<ProcessWithUserNames[]> {
|
async getAll(): Promise<ProcessWithUserNames[]> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('processes')
|
.from('processes')
|
||||||
.select('*, owner:users!owner_id(name, avatar_url), updater:users!updated_by(name)')
|
.select('*, owner:users!owner_id(name, avatar_url), updater:users!updated_by(name), org:organizations!org_id(name), area:areas!area_id(name)')
|
||||||
.order('updated_at', { ascending: false })
|
.order('updated_at', { ascending: false })
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
return (data ?? []).map((row) => fromRowWithUsers(row as Record<string, unknown>))
|
return (data ?? []).map((row) => fromRowWithUsers(row as Record<string, unknown>))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { lazy, Suspense, useEffect } from 'react'
|
import { lazy, Suspense, useEffect } from 'react'
|
||||||
import { createRouter, createRootRoute, createRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
|
import { createRouter, createRootRoute, createRoute, redirect, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||||
import { WorkspacePageWithBoundary } from '@/features/workspace/WorkspacePage'
|
import { WorkspacePageWithBoundary } from '@/features/workspace/WorkspacePage'
|
||||||
import { ImportPage } from '@/features/import/ImportPage'
|
import { ImportPage } from '@/features/import/ImportPage'
|
||||||
import { LibraryPage } from '@/features/library/LibraryPage'
|
import { LibraryPage } from '@/features/library/LibraryPage'
|
||||||
@@ -107,6 +107,9 @@ const libraryRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/library',
|
path: '/library',
|
||||||
component: LibraryPage,
|
component: LibraryPage,
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
org: typeof search.org === 'string' ? search.org : undefined,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const importRoute = createRoute({
|
const importRoute = createRoute({
|
||||||
@@ -131,6 +134,12 @@ const settingsRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
component: SettingsPage,
|
component: SettingsPage,
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
const role = (context as { auth?: { user?: { platformRole?: string } } }).auth?.user?.platformRole
|
||||||
|
if (role === 'client_editor' || role === 'client_viewer') {
|
||||||
|
throw redirect({ to: '/library', search: { org: undefined } })
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const resourcesRoute = createRoute({
|
const resourcesRoute = createRoute({
|
||||||
|
|||||||
99
supabase/functions/delete-user/index.ts
Normal file
99
supabase/functions/delete-user/index.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Edge Function: delete-user
|
||||||
|
* Propósito: eliminación permanente de un usuario (hard delete de auth.users + public.users).
|
||||||
|
* Solo accesible por platform_admin. No reversible.
|
||||||
|
* Variables de entorno: SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY (auto-disponibles en Edge Functions).
|
||||||
|
* Deploy: supabase functions deploy delete-user
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||||
|
|
||||||
|
interface DeleteUserRequest {
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req: Request) => {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response('ok', { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return new Response(JSON.stringify({ error: 'Método no permitido' }), { status: 405, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ', '')
|
||||||
|
if (!callerToken) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Authorization header requerido' }), { status: 401, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: { user: caller }, error: authError } = await adminClient.auth.getUser(callerToken)
|
||||||
|
if (authError || !caller) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Token inválido o expirado' }), { status: 401, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: callerProfile, error: profileError } = await adminClient
|
||||||
|
.from('users')
|
||||||
|
.select('platform_role')
|
||||||
|
.eq('id', caller.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (profileError || callerProfile?.platform_role !== 'platform_admin') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Forbidden: solo platform_admin puede eliminar usuarios' }),
|
||||||
|
{ status: 403, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Parsear body ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let body: DeleteUserRequest
|
||||||
|
try {
|
||||||
|
body = await req.json() as DeleteUserRequest
|
||||||
|
} catch {
|
||||||
|
return new Response(JSON.stringify({ error: 'Body JSON inválido' }), { status: 400, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = body
|
||||||
|
if (!userId?.trim()) {
|
||||||
|
return new Response(JSON.stringify({ error: 'El campo userId es requerido' }), { status: 400, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Prevenir auto-eliminación ──────────────────────────────────────────
|
||||||
|
|
||||||
|
if (userId === caller.id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No puedes eliminar tu propia cuenta' }),
|
||||||
|
{ status: 400, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Eliminar de public.users primero (CASCADE, pero por seguridad explícita) ─
|
||||||
|
|
||||||
|
await adminClient.from('users').delete().eq('id', userId)
|
||||||
|
|
||||||
|
// ── 5. Hard delete de auth.users ─────────────────────────────────────────
|
||||||
|
|
||||||
|
const { error: deleteError } = await adminClient.auth.admin.deleteUser(userId)
|
||||||
|
if (deleteError) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Error al eliminar usuario: ${deleteError.message}` }),
|
||||||
|
{ status: 500, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ success: true }), { status: 200, headers })
|
||||||
|
})
|
||||||
74
supabase/functions/list-users/index.ts
Normal file
74
supabase/functions/list-users/index.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Edge Function: list-users
|
||||||
|
* Propósito: devuelve usuarios de public.users con email de auth.users (requiere service_role).
|
||||||
|
* Solo accesible por platform_admin.
|
||||||
|
* Deploy: supabase functions deploy list-users
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||||
|
|
||||||
|
Deno.serve(async (req: Request) => {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response('ok', { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ', '')
|
||||||
|
if (!callerToken) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Authorization header requerido' }), { status: 401, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: { user: caller }, error: authError } = await adminClient.auth.getUser(callerToken)
|
||||||
|
if (authError || !caller) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Token inválido o expirado' }), { status: 401, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: callerProfile, error: profileError } = await adminClient
|
||||||
|
.from('users')
|
||||||
|
.select('platform_role')
|
||||||
|
.eq('id', caller.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (profileError || callerProfile?.platform_role !== 'platform_admin') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Forbidden: solo platform_admin puede listar usuarios con email' }),
|
||||||
|
{ status: 403, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Obtener emails de auth.users ───────────────────────────────────────
|
||||||
|
|
||||||
|
const { data: authData, error: listError } = await adminClient.auth.admin.listUsers({
|
||||||
|
page: 1,
|
||||||
|
perPage: 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (listError) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Error al listar usuarios: ${listError.message}` }),
|
||||||
|
{ status: 500, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Devolver mapa id → email ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const result = (authData?.users ?? []).map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
email: u.email ?? null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(result), { status: 200, headers })
|
||||||
|
})
|
||||||
59
supabase/migrations/017_create_areas.sql
Normal file
59
supabase/migrations/017_create_areas.sql
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
-- Migración 017: tabla areas + area_id en processes + RLS + bulk-assign org
|
||||||
|
-- Completamente aditiva — no elimina columnas, tablas ni políticas existentes.
|
||||||
|
|
||||||
|
-- ── Paso 1: Crear tabla areas ─────────────────────────────────────────────────
|
||||||
|
-- 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 ───────────────────────────────────────
|
||||||
|
-- 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 ────────────────────────────────────────────────────
|
||||||
|
-- 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 ─────────
|
||||||
|
-- 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;
|
||||||
15
supabase/migrations/018_client_editor_insert.sql
Normal file
15
supabase/migrations/018_client_editor_insert.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- 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
|
||||||
|
);
|
||||||
143
supabase/seed_client_processes.sql
Normal file
143
supabase/seed_client_processes.sql
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
-- seed_client_processes.sql
|
||||||
|
-- Distribuye procesos de InQuality a los demás clientes para testing.
|
||||||
|
-- Duplica entre 5 y 10 procesos aleatorios por org (deep copy: proceso + actividades + gateways + resource assignments).
|
||||||
|
-- Los procesos de InQuality NO se modifican ni eliminan.
|
||||||
|
-- Ejecutar desde el SQL Editor de Supabase (o psql con service_role).
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
inquality_org_id uuid;
|
||||||
|
client_org record;
|
||||||
|
source_proc record;
|
||||||
|
new_proc_id uuid;
|
||||||
|
source_act record;
|
||||||
|
new_act_id uuid;
|
||||||
|
proc_count int := 0;
|
||||||
|
BEGIN
|
||||||
|
-- Obtener InQuality (is_provider = true)
|
||||||
|
SELECT id INTO inquality_org_id
|
||||||
|
FROM organizations
|
||||||
|
WHERE is_provider = true
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF inquality_org_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'No se encontró una organización con is_provider = true';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'InQuality org_id: %', inquality_org_id;
|
||||||
|
|
||||||
|
-- Para cada org cliente
|
||||||
|
FOR client_org IN
|
||||||
|
SELECT id, name
|
||||||
|
FROM organizations
|
||||||
|
WHERE is_provider = false
|
||||||
|
ORDER BY name
|
||||||
|
LOOP
|
||||||
|
proc_count := 0;
|
||||||
|
RAISE NOTICE '→ Distribuyendo a: %', client_org.name;
|
||||||
|
|
||||||
|
-- Seleccionar entre 5 y 10 procesos aleatorios de InQuality
|
||||||
|
FOR source_proc IN
|
||||||
|
SELECT *
|
||||||
|
FROM processes
|
||||||
|
WHERE org_id = inquality_org_id
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT 5 + FLOOR(RANDOM() * 6)::int -- 5..10 inclusive
|
||||||
|
LOOP
|
||||||
|
new_proc_id := gen_random_uuid();
|
||||||
|
|
||||||
|
-- 1. Duplicar proceso
|
||||||
|
INSERT INTO processes (
|
||||||
|
id, name, client, bpmn_xml, currency,
|
||||||
|
overhead_percentage, annual_frequency, analysis_horizon_years,
|
||||||
|
automation_investment, group_id, area_id, org_id, tags,
|
||||||
|
owner_id, created_by, updated_by, updated_at
|
||||||
|
) VALUES (
|
||||||
|
new_proc_id,
|
||||||
|
source_proc.name,
|
||||||
|
source_proc.client,
|
||||||
|
source_proc.bpmn_xml,
|
||||||
|
source_proc.currency,
|
||||||
|
source_proc.overhead_percentage,
|
||||||
|
source_proc.annual_frequency,
|
||||||
|
source_proc.analysis_horizon_years,
|
||||||
|
source_proc.automation_investment,
|
||||||
|
NULL, -- group_id: columna legacy, no asignar
|
||||||
|
NULL, -- area_id: sin área; asignar manualmente si aplica
|
||||||
|
client_org.id, -- org destino
|
||||||
|
source_proc.tags,
|
||||||
|
source_proc.owner_id,
|
||||||
|
source_proc.created_by,
|
||||||
|
source_proc.updated_by,
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Duplicar actividades + sus resource assignments
|
||||||
|
FOR source_act IN
|
||||||
|
SELECT * FROM activities WHERE process_id = source_proc.id
|
||||||
|
LOOP
|
||||||
|
new_act_id := gen_random_uuid();
|
||||||
|
|
||||||
|
INSERT INTO activities (
|
||||||
|
id, process_id, bpmn_element_id, name, type,
|
||||||
|
direct_cost_fixed, execution_time_minutes,
|
||||||
|
automatable, automated_cost_fixed, automated_time_minutes,
|
||||||
|
created_by, updated_by, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
new_act_id,
|
||||||
|
new_proc_id,
|
||||||
|
source_act.bpmn_element_id,
|
||||||
|
source_act.name,
|
||||||
|
source_act.type,
|
||||||
|
source_act.direct_cost_fixed,
|
||||||
|
source_act.execution_time_minutes,
|
||||||
|
source_act.automatable,
|
||||||
|
source_act.automated_cost_fixed,
|
||||||
|
source_act.automated_time_minutes,
|
||||||
|
source_act.created_by,
|
||||||
|
source_act.updated_by,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Copiar resource assignments para esta actividad (si los hay)
|
||||||
|
INSERT INTO activity_resource_assignments (
|
||||||
|
id, activity_id, resource_id, utilization_minutes, units, scenario
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
new_act_id,
|
||||||
|
resource_id,
|
||||||
|
utilization_minutes,
|
||||||
|
units,
|
||||||
|
scenario
|
||||||
|
FROM activity_resource_assignments
|
||||||
|
WHERE activity_id = source_act.id;
|
||||||
|
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- 3. Duplicar gateways
|
||||||
|
INSERT INTO gateways (
|
||||||
|
id, process_id, bpmn_element_id, gateway_type, branches,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
new_proc_id,
|
||||||
|
bpmn_element_id,
|
||||||
|
gateway_type,
|
||||||
|
branches,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
FROM gateways
|
||||||
|
WHERE process_id = source_proc.id;
|
||||||
|
|
||||||
|
proc_count := proc_count + 1;
|
||||||
|
RAISE NOTICE ' ✓ %', source_proc.name;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RAISE NOTICE ' → % procesos duplicados para %', proc_count, client_org.name;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Script completado.';
|
||||||
|
END $$;
|
||||||
@@ -74,6 +74,7 @@ vi.mock('@/components/AppHeader', () => ({ AppHeader: () => null }))
|
|||||||
vi.mock('@tanstack/react-router', () => ({
|
vi.mock('@tanstack/react-router', () => ({
|
||||||
useNavigate: () => vi.fn(),
|
useNavigate: () => vi.fn(),
|
||||||
useRouterState: () => ({ location: { search: '' } }),
|
useRouterState: () => ({ location: { search: '' } }),
|
||||||
|
useLocation: () => ({ search: '' }),
|
||||||
Link: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
Link: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -133,9 +134,9 @@ describe('LibraryPage — visibilidad de controles según rol', () => {
|
|||||||
authUserRef.current = { ...authUserRef.current, platformRole: 'client_editor' }
|
authUserRef.current = { ...authUserRef.current, platformRole: 'client_editor' }
|
||||||
})
|
})
|
||||||
|
|
||||||
it('NO muestra el botón "Importar BPMN"', () => {
|
it('muestra el botón "Importar BPMN"', () => {
|
||||||
render(<LibraryPage />)
|
render(<LibraryPage />)
|
||||||
expect(screen.queryByText('Importar BPMN')).not.toBeInTheDocument()
|
expect(screen.getByText('Importar BPMN')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('NO muestra el panel de grupos', () => {
|
it('NO muestra el panel de grupos', () => {
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ describe('MethodologyFooter', () => {
|
|||||||
id: 'p1', name: 'Proceso Test', clientName: '',
|
id: 'p1', name: 'Proceso Test', clientName: '',
|
||||||
bpmnXml: '<def/>', currency: 'USD',
|
bpmnXml: '<def/>', currency: 'USD',
|
||||||
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow()
|
expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow()
|
||||||
})
|
})
|
||||||
@@ -239,7 +239,7 @@ describe('MethodologyFooter', () => {
|
|||||||
id: 'p1', name: 'Proc', clientName: '',
|
id: 'p1', name: 'Proc', clientName: '',
|
||||||
bpmnXml: '', currency: 'USD',
|
bpmnXml: '', currency: 'USD',
|
||||||
overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
render(<MethodologyFooter process={proc} />)
|
render(<MethodologyFooter process={proc} />)
|
||||||
const text = document.body.textContent ?? ''
|
const text = document.body.textContent ?? ''
|
||||||
@@ -251,7 +251,7 @@ describe('MethodologyFooter', () => {
|
|||||||
id: 'p1', name: 'Proc', clientName: '',
|
id: 'p1', name: 'Proc', clientName: '',
|
||||||
bpmnXml: '', currency: 'USD',
|
bpmnXml: '', currency: 'USD',
|
||||||
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
render(<MethodologyFooter process={proc} />)
|
render(<MethodologyFooter process={proc} />)
|
||||||
const text = document.body.textContent ?? ''
|
const text = document.body.textContent ?? ''
|
||||||
@@ -342,7 +342,7 @@ describe('ReportPage — con datos completos', () => {
|
|||||||
updatedAt: Date.now() - 1000 * 60 * 30,
|
updatedAt: Date.now() - 1000 * 60 * 30,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockSimulation = {
|
const mockSimulation = {
|
||||||
|
|||||||
@@ -600,7 +600,7 @@ const mockProcessBase = {
|
|||||||
currency: 'USD', overheadPercentage: 0.2,
|
currency: 'USD', overheadPercentage: 0.2,
|
||||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||||
createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000,
|
createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000,
|
||||||
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockSimulationBase = {
|
const mockSimulationBase = {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function makeProcess(extras: Partial<Process> = {}): Process {
|
|||||||
id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ',
|
id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ',
|
||||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, ...extras,
|
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null, ...extras,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function makeProcess(currency: string, extras: Partial<Process> = {}): Process {
|
|||||||
id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC',
|
id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC',
|
||||||
bpmnXml: '', currency, overheadPercentage: 0.2,
|
bpmnXml: '', currency, overheadPercentage: 0.2,
|
||||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||||
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, ...extras,
|
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null, ...extras,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe('Medición de tamaños de CSV — 3 sample BPMNs', () => {
|
|||||||
bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
|
bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
|
||||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||||
createdAt: 0, updatedAt: 0,
|
createdAt: 0, updatedAt: 0,
|
||||||
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const simulation: Simulation = {
|
const simulation: Simulation = {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const mockProcess: Process = {
|
|||||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||||
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
|
||||||
createdAt: 0, updatedAt: 0,
|
createdAt: 0, updatedAt: 0,
|
||||||
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const perActivityActual = [
|
const perActivityActual = [
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const mockProcess: Process = {
|
|||||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||||
createdAt: 0, updatedAt: 0,
|
createdAt: 0, updatedAt: 0,
|
||||||
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulación SIN recursos en ninguna actividad
|
// Simulación SIN recursos en ninguna actividad
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const mockProcess: Process = {
|
|||||||
overheadPercentage: 0.2,
|
overheadPercentage: 0.2,
|
||||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||||
createdAt: 0, updatedAt: 0,
|
createdAt: 0, updatedAt: 0,
|
||||||
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockSimulation: Simulation = {
|
const mockSimulation: Simulation = {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const mockProcess: Process = {
|
|||||||
overheadPercentage: 0.2,
|
overheadPercentage: 0.2,
|
||||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000,
|
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000,
|
||||||
createdAt: 0, updatedAt: 0,
|
createdAt: 0, updatedAt: 0,
|
||||||
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockSimulation: Simulation = {
|
const mockSimulation: Simulation = {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const mockProcess: Process = {
|
|||||||
overheadPercentage: 0.2,
|
overheadPercentage: 0.2,
|
||||||
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
||||||
createdAt: 0, updatedAt: 0,
|
createdAt: 0, updatedAt: 0,
|
||||||
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockSimulation: Simulation = {
|
const mockSimulation: Simulation = {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const mockProcess: Process = {
|
|||||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||||
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
||||||
createdAt: 0, updatedAt: 0,
|
createdAt: 0, updatedAt: 0,
|
||||||
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockRoi: RoiResult = {
|
const mockRoi: RoiResult = {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const mockProcess: Process = {
|
|||||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||||
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
|
||||||
createdAt: 0, updatedAt: 0,
|
createdAt: 0, updatedAt: 0,
|
||||||
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockSimulation: Simulation = {
|
const mockSimulation: Simulation = {
|
||||||
|
|||||||
94
tests/persistence/area-repo.test.ts
Normal file
94
tests/persistence/area-repo.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { supabaseAreaRepo } from '@/persistence/supabase/area-repo'
|
||||||
|
|
||||||
|
const { mockSingle, mockData } = vi.hoisted(() => ({
|
||||||
|
mockSingle: vi.fn(),
|
||||||
|
mockData: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/supabase', () => {
|
||||||
|
const buildChain = () => {
|
||||||
|
const chain: Record<string, unknown> = {}
|
||||||
|
chain.select = vi.fn().mockReturnValue(chain)
|
||||||
|
chain.insert = vi.fn().mockReturnValue(chain)
|
||||||
|
chain.update = vi.fn().mockReturnValue(chain)
|
||||||
|
chain.delete = vi.fn().mockReturnValue(chain)
|
||||||
|
chain.eq = vi.fn().mockReturnValue(chain)
|
||||||
|
chain.order = vi.fn().mockImplementation(() => mockData())
|
||||||
|
chain.single = mockSingle
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
supabase: {
|
||||||
|
from: vi.fn().mockImplementation(() => buildChain()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const AREA_ROW = {
|
||||||
|
id: 'area-uuid-1',
|
||||||
|
org_id: 'org-uuid-1',
|
||||||
|
name: 'Finanzas',
|
||||||
|
description: 'Área de finanzas',
|
||||||
|
created_at: '2026-07-07T00:00:00.000Z',
|
||||||
|
updated_at: '2026-07-07T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('supabaseAreaRepo', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getByOrg', () => {
|
||||||
|
it('devuelve lista de áreas mapeadas correctamente', async () => {
|
||||||
|
mockData.mockResolvedValue({ data: [AREA_ROW], error: null })
|
||||||
|
|
||||||
|
const result = await supabaseAreaRepo.getByOrg('org-uuid-1')
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
id: 'area-uuid-1',
|
||||||
|
orgId: 'org-uuid-1',
|
||||||
|
name: 'Finanzas',
|
||||||
|
description: 'Área de finanzas',
|
||||||
|
createdAt: new Date('2026-07-07T00:00:00.000Z').getTime(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('devuelve array vacío si la org no tiene áreas', async () => {
|
||||||
|
mockData.mockResolvedValue({ data: [], error: null })
|
||||||
|
|
||||||
|
const result = await supabaseAreaRepo.getByOrg('org-sin-areas')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('inserta el área y retorna el registro mapeado', async () => {
|
||||||
|
mockSingle.mockResolvedValue({ data: AREA_ROW, error: null })
|
||||||
|
|
||||||
|
const result = await supabaseAreaRepo.create({
|
||||||
|
orgId: 'org-uuid-1',
|
||||||
|
name: 'Finanzas',
|
||||||
|
description: 'Área de finanzas',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
id: 'area-uuid-1',
|
||||||
|
orgId: 'org-uuid-1',
|
||||||
|
name: 'Finanzas',
|
||||||
|
description: 'Área de finanzas',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propaga el error de Supabase si el insert falla', async () => {
|
||||||
|
mockSingle.mockResolvedValue({ data: null, error: new Error('RLS violation') })
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
supabaseAreaRepo.create({ orgId: 'org-1', name: 'Test' })
|
||||||
|
).rejects.toThrow('RLS violation')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -94,7 +94,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
|
|||||||
currency: 'USD', overheadPercentage: 0.2,
|
currency: 'USD', overheadPercentage: 0.2,
|
||||||
annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000,
|
annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000,
|
||||||
createdAt: Date.now(), updatedAt: Date.now(),
|
createdAt: Date.now(), updatedAt: Date.now(),
|
||||||
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
|
groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
|
||||||
}
|
}
|
||||||
useProcessStore.setState({ currentProcess: proc })
|
useProcessStore.setState({ currentProcess: proc })
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user