This commit is contained in:
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
|
||||
Reference in New Issue
Block a user