Compare commits
9 Commits
d64078d96a
...
de00e3a902
| Author | SHA1 | Date | |
|---|---|---|---|
| de00e3a902 | |||
| aa0ab72f17 | |||
| cbf3a88e78 | |||
| b99cc75f80 | |||
| 27336fe0c7 | |||
| e8490a5863 | |||
| d6e7226842 | |||
| f9e886e12d | |||
| a3ff6ee545 |
@@ -6,6 +6,13 @@ 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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Added
|
||||||
|
- LibraryPage: agrupación org → área con headers colapsables para admin (Sprint 8)
|
||||||
|
- Settings ⚙ oculto para roles cliente + guard beforeLoad en /settings (Sprint 8)
|
||||||
|
- Interface `Area` en domain/types.ts (Sprint 8)
|
||||||
|
- Campos `areaId`/`areaName` en `Process` (Sprint 8)
|
||||||
|
- Repositorio `supabaseAreaRepo` con CRUD completo para tabla `areas` (Sprint 8)
|
||||||
|
- `fromRow`/`toRow`/`updateEditable`/`getById` en process-repo extendidos con `area_id` (Sprint 8)
|
||||||
|
|
||||||
## [0.8.0] - 2026-07-07
|
## [0.8.0] - 2026-07-07
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
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
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export function ImportPage() {
|
|||||||
ownerId: user!.id,
|
ownerId: user!.id,
|
||||||
updatedBy: user!.id,
|
updatedBy: user!.id,
|
||||||
orgId: null,
|
orgId: null,
|
||||||
|
areaId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const activityElements = extractActivityElements(graph)
|
const activityElements = extractActivityElements(graph)
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import { useNavigate, useRouterState } from '@tanstack/react-router'
|
import { useNavigate } 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'
|
import { AppFooter } from '@/components/AppFooter'
|
||||||
|
|
||||||
@@ -267,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[]>([])
|
||||||
@@ -399,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))
|
||||||
: []
|
: []
|
||||||
@@ -522,26 +452,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)
|
|
||||||
return (
|
|
||||||
<ProcessCardWithSim
|
<ProcessCardWithSim
|
||||||
key={p.id}
|
key={p.id}
|
||||||
process={p}
|
process={p}
|
||||||
showGroupChip
|
showGroupChip
|
||||||
groupName={group?.name}
|
groupName={p.areaName ?? p.orgName ?? undefined}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
currentUserName={currentUserName}
|
currentUserName={currentUserName}
|
||||||
onShared={onShared}
|
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">
|
||||||
@@ -575,59 +502,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">
|
return (
|
||||||
{groups.length}
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<ChevronDown
|
||||||
{/* Grid de grupos */}
|
className="h-4 w-4 text-muted-foreground transition-transform duration-200"
|
||||||
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
|
style={{ transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
|
||||||
{groups.map((group) => {
|
|
||||||
const groupProcesses = visibleProcesses.filter((p) => p.groupId === group.id)
|
|
||||||
const lastUpdated = groupProcesses.length > 0
|
|
||||||
? Math.max(...groupProcesses.map((p) => p.updatedAt))
|
|
||||||
: null
|
|
||||||
return (
|
|
||||||
<div key={group.id} id={`group-${group.id}`}>
|
|
||||||
<GroupCard
|
|
||||||
group={group}
|
|
||||||
processCount={groupProcesses.length}
|
|
||||||
lastUpdated={lastUpdated}
|
|
||||||
onClick={() => onNavigateToGroup(group.id)}
|
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Contenido — animado */}
|
||||||
|
<div
|
||||||
|
className="transition-all duration-200 overflow-hidden"
|
||||||
|
style={{ maxHeight: isCollapsed ? 0 : '9999px', opacity: isCollapsed ? 0 : 1 }}
|
||||||
|
>
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{areaGroups.map(({ areaId, areaName, processes: areaProcesses }) => (
|
||||||
|
<div key={areaId}>
|
||||||
|
{/* Sub-header de área: visible si hay >1 área o si el área tiene nombre */}
|
||||||
|
{(areaGroups.length > 1 || areaId !== '__sin_area') && (
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-2 flex items-center gap-1.5">
|
||||||
|
<FolderOpen className="h-3 w-3" />
|
||||||
|
{areaName}
|
||||||
|
<span className="normal-case tracking-normal font-normal">
|
||||||
|
· {areaProcesses.length}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{areaProcesses.map((p) => (
|
||||||
|
<ProcessCardWithSim
|
||||||
|
key={p.id}
|
||||||
|
process={p}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
currentUserName={currentUserName}
|
||||||
|
onShared={onShared}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<NewGroupCard
|
|
||||||
label={settings.groupLabel}
|
|
||||||
onCreate={(name) => createGroup(name, user!.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sin clasificar */}
|
{/* Empty state: admin sin procesos */}
|
||||||
{unclassifiedProcesses.length > 0 && (
|
{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
|
<button
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all hover:border-border/80"
|
onClick={() => onImport()}
|
||||||
style={{ border: '0.5px dashed hsl(var(--border))', background: 'hsl(var(--card))', borderRadius: '0.5rem' }}
|
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white"
|
||||||
onClick={() => onNavigateToGroup(null)}
|
style={{ background: '#F59845' }}
|
||||||
>
|
>
|
||||||
<FolderX className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
<Upload className="h-4 w-4" />
|
||||||
<div className="flex-1 min-w-0">
|
Importar BPMN
|
||||||
<span className="text-[13px] font-medium text-secondary-foreground">
|
|
||||||
Procesos sin {settings.groupLabel.toLowerCase()} asignado
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[11px] text-muted-foreground flex-shrink-0">
|
|
||||||
{unclassifiedProcesses.length} proceso{unclassifiedProcesses.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</button>
|
</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) */}
|
||||||
@@ -774,10 +731,8 @@ function GroupView({
|
|||||||
|
|
||||||
export function LibraryPage() {
|
export function LibraryPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { load, processes, groups, settings, isLoading } = useLibraryStore()
|
const { load, processes } = 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)
|
||||||
@@ -789,25 +744,6 @@ export function LibraryPage() {
|
|||||||
load()
|
load()
|
||||||
}, [load, user?.id])
|
}, [load, user?.id])
|
||||||
|
|
||||||
// Cuando la biblioteca cargó con ?org=<uuid>, hacer scroll al grupo que contiene ese org
|
|
||||||
useEffect(() => {
|
|
||||||
if (!orgParam || isLoading || processes.length === 0) return
|
|
||||||
const proc = processes.find((p) => p.orgId === orgParam)
|
|
||||||
if (!proc?.groupId) return
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
document.getElementById(`group-${proc.groupId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
||||||
})
|
|
||||||
}, [orgParam, isLoading, processes])
|
|
||||||
|
|
||||||
function navigateToGroup(groupId: string | null) {
|
|
||||||
setTransitioning(true)
|
|
||||||
setTimeout(() => {
|
|
||||||
setActiveGroupId(groupId)
|
|
||||||
setView('group')
|
|
||||||
setTransitioning(false)
|
|
||||||
}, 150)
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateHome() {
|
function navigateHome() {
|
||||||
setTransitioning(true)
|
setTransitioning(true)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -817,13 +753,9 @@ export function LibraryPage() {
|
|||||||
}, 150)
|
}, 150)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleImport(groupId?: string) {
|
function handleImport() {
|
||||||
if (groupId) {
|
|
||||||
navigate({ to: '/import', search: { groupId } })
|
|
||||||
} else {
|
|
||||||
navigate({ to: '/import' })
|
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.
|
||||||
// Evita el flash donde 'guest' se muestra como platform_admin durante la ventana de sync.
|
// Evita el flash donde 'guest' se muestra como platform_admin durante la ventana de sync.
|
||||||
@@ -848,8 +780,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">
|
||||||
@@ -860,7 +792,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">
|
||||||
@@ -888,6 +823,7 @@ export function LibraryPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isClientRole && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/settings' })}
|
onClick={() => navigate({ to: '/settings' })}
|
||||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||||
@@ -895,9 +831,10 @@ export function LibraryPage() {
|
|||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
{!isClientRole && (
|
{!isClientRole && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleImport(view === 'group' && activeGroupId ? activeGroupId : undefined)}
|
onClick={handleImport}
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
|
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' }}
|
||||||
>
|
>
|
||||||
@@ -915,7 +852,6 @@ export function LibraryPage() {
|
|||||||
>
|
>
|
||||||
{view === 'home' ? (
|
{view === 'home' ? (
|
||||||
<HomeView
|
<HomeView
|
||||||
onNavigateToGroup={navigateToGroup}
|
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
ownerFilter={ownerFilter}
|
ownerFilter={ownerFilter}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
|
|||||||
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,7 @@ 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,
|
||||||
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 +26,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 +46,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 +96,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 +108,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 +121,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'
|
||||||
@@ -131,6 +131,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' })
|
||||||
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const resourcesRoute = createRoute({
|
const resourcesRoute = createRoute({
|
||||||
|
|||||||
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;
|
||||||
@@ -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