diff --git a/sprints/sprint-8/ETAPA_1_PROMPT.md b/sprints/sprint-8/ETAPA_1_PROMPT.md new file mode 100644 index 0000000..6831eb3 --- /dev/null +++ b/sprints/sprint-8/ETAPA_1_PROMPT.md @@ -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) diff --git a/sprints/sprint-8/ETAPA_2_PROMPT.md b/sprints/sprint-8/ETAPA_2_PROMPT.md new file mode 100644 index 0000000..8612414 --- /dev/null +++ b/sprints/sprint-8/ETAPA_2_PROMPT.md @@ -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): 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 { + 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 { + 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) + }, + + // Actualizar nombre y/o descripción de un área + async update(id: string, data: { name?: string; description?: string }): Promise { + 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) + }, + + // Eliminar área. Los procesos con esta área pasan a area_id = null (ON DELETE SET NULL) + async delete(id: string): Promise { + 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` diff --git a/sprints/sprint-8/ETAPA_3_PROMPT.md b/sprints/sprint-8/ETAPA_3_PROMPT.md new file mode 100644 index 0000000..34262be --- /dev/null +++ b/sprints/sprint-8/ETAPA_3_PROMPT.md @@ -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() + + 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() + + 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>(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 && ( +
+ {/* 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 ( +
+ {/* Header de org — colapsable */} + + + {/* Contenido — animado */} +
+
+ {areaGroups.map(({ areaId, areaName, processes: areaProcesses }) => ( +
+ {/* Sub-header de área (solo si hay más de un área o existe un área nombrada) */} + {(areaGroups.length > 1 || areaId !== '__sin_area') && ( +

+ + {areaName} + + · {areaProcesses.length} + +

+ )} +
+ {areaProcesses.map((p) => ( + + ))} +
+
+ ))} +
+
+
+ ) + })} + + {/* Empty state: admin sin procesos */} + {orgGroups.length === 0 && ( +
+ +

+ Tu biblioteca está vacía +

+

+ Importá tu primer BPMN para empezar +

+ +
+ )} +
+)} +``` + +**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) + + +// Después: + +``` + +### 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 && ( + +)} +``` + +### 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 diff --git a/supabase/migrations/017_create_areas.sql b/supabase/migrations/017_create_areas.sql new file mode 100644 index 0000000..7710f1f --- /dev/null +++ b/supabase/migrations/017_create_areas.sql @@ -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;