Compare commits

..

9 Commits

Author SHA1 Message Date
de00e3a902 etapa 3 terminada
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 20:19:45 -03:00
aa0ab72f17 docs(changelog): etapa 3 sprint 8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 20:17:19 -03:00
cbf3a88e78 feat(library): agrupación org → área reemplaza groupId en HomeView
- Admin ve procesos agrupados por org (colapsable) → sub-secciones por área
- Búsqueda muestra chip con areaName ?? orgName en lugar de nombre de grupo
- Header muestra conteo de clientes (orgs) en vez de groups
- Botón Settings ⚙ oculto para client_editor y client_viewer
- Efecto orgParam/groupId scroll obsoleto eliminado
- handleImport simplificado (sin groupId)
- data-testid="groups-header" preservado en el nuevo contenedor admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 20:17:08 -03:00
b99cc75f80 feat(router): guard beforeLoad en /settings para client_editor y client_viewer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 20:16:56 -03:00
27336fe0c7 fix(process-repo): getAll() incluye JOIN con org y area para populear names
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 20:16:55 -03:00
e8490a5863 docs(changelog): etapa 2 sprint 8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 18:18:23 -03:00
d6e7226842 feat(process-repo): extender fromRow/toRow/getById con areaId y areaName
- fromRow: extrae areaId y areaName (vía JOIN en getById)
- toRow y updateEditable: incluyen area_id en escrituras a Supabase
- getById: JOIN con areas!area_id(name) para obtener nombre en una query
- Fixtures de tests actualizados con areaId: null (campo requerido)
- 4 tests nuevos en tests/persistence/area-repo.test.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 18:18:14 -03:00
f9e886e12d feat(area-repo): repositorio CRUD de áreas por organización
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 18:18:07 -03:00
a3ff6ee545 feat(domain): interface Area + campos areaId/areaName en Process
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 18:18:04 -03:00
26 changed files with 1314 additions and 236 deletions

View File

@@ -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).
## [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
### Added

View 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)

View 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`

View 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

View File

@@ -54,6 +54,7 @@ export const ProcessSchema = z.object({
automationInvestment: z.number().min(0).default(0),
createdAt: z.number(),
updatedAt: z.number(),
areaId: z.string().uuid().nullable(),
})
export type ResourceInput = z.infer<typeof ResourceSchema>

View File

@@ -22,6 +22,8 @@ export interface Process {
updatedBy: string // 🆕 Sprint 4 — UUID del usuario que hizo el último update
orgId: string | null // 🆕 Sprint 7 — organización dueña del proceso (null = proceso interno InQ)
orgName?: string | null // 🆕 Sprint 7 — nombre de la org (poblado por getById via JOIN, no se persiste)
areaId: string | null // 🆕 Sprint 8 — área de la org (null = sin área)
areaName?: string | null // 🆕 Sprint 8 — poblado via JOIN en getById
}
export type ActivityType = 'task' | 'subprocess'
@@ -89,6 +91,14 @@ export interface ProcessGroup {
updatedAt: number // timestamp Unix ms
}
export interface Area {
id: string
orgId: string
name: string
description: string | null
createdAt: number // timestamp Unix ms
}
export interface AppSettings {
id: 'singleton' // siempre un único registro por instalación
groupLabel: string // etiqueta configurable para el nivel de agrupación. Default: 'Cliente'

View File

@@ -92,6 +92,7 @@ export function ImportPage() {
ownerId: user!.id,
updatedBy: user!.id,
orgId: null,
areaId: null,
}
const activityElements = extractActivityElements(graph)

View File

@@ -1,15 +1,16 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { useNavigate, useRouterState } from '@tanstack/react-router'
import { useEffect, useState, useMemo } from 'react'
import { useNavigate } from '@tanstack/react-router'
import {
Upload, Search, Plus, Building2, FolderX, GitBranch,
Upload, Search, Building2, GitBranch,
Clock, TrendingUp, BarChart2, FileX2, X, Settings,
MoreVertical, Trash2, Share2, Lock, User,
ChevronDown, FolderOpen,
} from 'lucide-react'
import { useLibraryStore } from '@/store/library-store'
import { useAuth } from '@/auth/AuthContext'
import { supabaseProcessRepo, type ProcessWithUserNames } from '@/persistence/supabase/process-repo'
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 { 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 ────────────────────────────────────────────────────────────────
function HomeView({
onNavigateToGroup,
onImport,
ownerFilter,
currentUserId,
currentUserName,
onShared,
}: {
onNavigateToGroup: (groupId: string | null) => void
onImport: (groupId?: string) => void
onImport: () => void
ownerFilter: 'all' | 'mine'
currentUserId: string
currentUserName: string
onShared: () => void
}) {
const { groups, processes, settings, createGroup, isLoading, error, load } = useLibraryStore()
const { groups, processes, isLoading, error, load } = useLibraryStore()
const { user } = useAuth()
const [search, setSearch] = useState('')
const [activeTags, setActiveTags] = useState<string[]>([])
@@ -399,10 +293,46 @@ function HomeView({
: processes
const cloud = tagCloudData(visibleProcesses)
const unclassifiedProcesses = visibleProcesses.filter((p) => p.groupId == null)
const isSearching = search.trim().length > 0 || activeTags.length > 0
const [collapsedOrgs, setCollapsedOrgs] = useState<Set<string>>(new Set())
const orgGroups = useMemo(() => {
const byOrg = new Map<string, { orgId: string; orgName: string; processes: ProcessWithUserNames[] }>()
for (const p of visibleProcesses) {
const key = p.orgId ?? '__sin_org'
const orgName = p.orgName ?? 'Sin organización'
if (!byOrg.has(key)) byOrg.set(key, { orgId: key, orgName, processes: [] })
byOrg.get(key)!.processes.push(p)
}
return [...byOrg.values()].sort((a, b) => a.orgName.localeCompare(b.orgName))
}, [visibleProcesses])
function getAreaGroups(orgProcesses: ProcessWithUserNames[]) {
const byArea = new Map<string, { areaId: string; areaName: string; processes: ProcessWithUserNames[] }>()
for (const p of orgProcesses) {
const key = p.areaId ?? '__sin_area'
const areaName = p.areaName ?? 'Sin área'
if (!byArea.has(key)) byArea.set(key, { areaId: key, areaName, processes: [] })
byArea.get(key)!.processes.push(p)
}
return [...byArea.values()].sort((a, b) => {
if (a.areaId === '__sin_area') return 1
if (b.areaId === '__sin_area') return -1
return a.areaName.localeCompare(b.areaName)
})
}
function toggleOrg(orgId: string) {
setCollapsedOrgs(prev => {
const next = new Set(prev)
if (next.has(orgId)) next.delete(orgId)
else next.add(orgId)
return next
})
}
const filteredProcesses = isSearching
? visibleProcesses.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
: []
@@ -522,26 +452,23 @@ function HomeView({
{filteredProcesses.length === 0 && (
<p className="text-[13px] text-muted-foreground py-4 text-center">Sin resultados para esa búsqueda.</p>
)}
{filteredProcesses.map((p) => {
const group = groups.find((g) => g.id === p.groupId)
return (
{filteredProcesses.map((p) => (
<ProcessCardWithSim
key={p.id}
process={p}
showGroupChip
groupName={group?.name}
groupName={p.areaName ?? p.orgName ?? undefined}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
)
})}
))}
</div>
)}
{/* Vista normal — grupos (solo roles internos) */}
{/* Vista normal — org → área (solo roles internos) */}
{!isSearching && !isClientRole && (
<>
<div className="space-y-3" data-testid="groups-header">
{/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */}
{cloud.length >= 3 && (
<div className="flex flex-wrap items-center gap-2">
@@ -575,59 +502,89 @@ function HomeView({
</div>
)}
{/* Header sección grupos */}
<div className="flex items-center gap-2" data-testid="groups-header">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{settings.groupLabel}s
</span>
<span className="text-[10px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground">
{groups.length}
{/* 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>
{/* Grid de grupos */}
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
{groups.map((group) => {
const groupProcesses = visibleProcesses.filter((p) => p.groupId === group.id)
const lastUpdated = groupProcesses.length > 0
? Math.max(...groupProcesses.map((p) => p.updatedAt))
: null
return (
<div key={group.id} id={`group-${group.id}`}>
<GroupCard
group={group}
processCount={groupProcesses.length}
lastUpdated={lastUpdated}
onClick={() => onNavigateToGroup(group.id)}
<ChevronDown
className="h-4 w-4 text-muted-foreground transition-transform duration-200"
style={{ transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
/>
</button>
{/* Contenido — animado */}
<div
className="transition-all duration-200 overflow-hidden"
style={{ maxHeight: isCollapsed ? 0 : '9999px', opacity: isCollapsed ? 0 : 1 }}
>
<div className="p-3 space-y-3">
{areaGroups.map(({ areaId, areaName, processes: areaProcesses }) => (
<div key={areaId}>
{/* Sub-header de área: visible si hay >1 área o si el área tiene nombre */}
{(areaGroups.length > 1 || areaId !== '__sin_area') && (
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-2 flex items-center gap-1.5">
<FolderOpen className="h-3 w-3" />
{areaName}
<span className="normal-case tracking-normal font-normal">
· {areaProcesses.length}
</span>
</p>
)}
<div className="space-y-2">
{areaProcesses.map((p) => (
<ProcessCardWithSim
key={p.id}
process={p}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
})}
<NewGroupCard
label={settings.groupLabel}
onCreate={(name) => createGroup(name, user!.id)}
/>
</div>
{/* Sin clasificar */}
{unclassifiedProcesses.length > 0 && (
{/* Empty state: admin sin procesos */}
{orgGroups.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Building2 className="h-12 w-12 text-muted-foreground/30" />
<p className="text-[13px] font-medium text-secondary-foreground">
Tu biblioteca está vacía
</p>
<p className="text-[13px] text-muted-foreground">
Importá tu primer BPMN para empezar
</p>
<button
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all hover:border-border/80"
style={{ border: '0.5px dashed hsl(var(--border))', background: 'hsl(var(--card))', borderRadius: '0.5rem' }}
onClick={() => onNavigateToGroup(null)}
onClick={() => onImport()}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white"
style={{ background: '#F59845' }}
>
<FolderX className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[13px] font-medium text-secondary-foreground">
Procesos sin {settings.groupLabel.toLowerCase()} asignado
</span>
</div>
<span className="text-[11px] text-muted-foreground flex-shrink-0">
{unclassifiedProcesses.length} proceso{unclassifiedProcesses.length !== 1 ? 's' : ''}
</span>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
</div>
)}
</>
</div>
)}
{/* Vista cliente: lista plana de procesos (sin grupos, RLS garantiza que solo ven los suyos) */}
@@ -774,10 +731,8 @@ function GroupView({
export function LibraryPage() {
const navigate = useNavigate()
const { load, processes, groups, settings, isLoading } = useLibraryStore()
const { load, processes } = useLibraryStore()
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 [activeGroupId, setActiveGroupId] = useState<string | null>(null)
const [transitioning, setTransitioning] = useState(false)
@@ -789,25 +744,6 @@ export function LibraryPage() {
load()
}, [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() {
setTransitioning(true)
setTimeout(() => {
@@ -817,13 +753,9 @@ export function LibraryPage() {
}, 150)
}
function handleImport(groupId?: string) {
if (groupId) {
navigate({ to: '/import', search: { groupId } })
} else {
function handleImport() {
navigate({ to: '/import' })
}
}
// Esperar a que syncProfileFromDB complete antes de renderizar UI rol-dependiente.
// Evita el flash donde 'guest' se muestra como platform_admin durante la ventana de sync.
@@ -848,8 +780,8 @@ export function LibraryPage() {
const currentUserId = user.id
const currentUserName = user.name
const totalGroups = groups.length
const isClientRole = user.platformRole === 'client_editor' || user.platformRole === 'client_viewer'
const orgCount = new Set(processes.map(p => p.orgId).filter(Boolean)).size
return (
<div className="min-h-screen bg-background">
@@ -860,7 +792,10 @@ export function LibraryPage() {
<div>
<h1 className="text-[18px] font-medium">Biblioteca de procesos</h1>
<p className="text-[12px] text-muted-foreground mt-0.5">
{processes.length} proceso{processes.length !== 1 ? 's' : ''} · {totalGroups} {settings.groupLabel.toLowerCase()}{totalGroups !== 1 ? 's' : ''}
{isClientRole
? `${processes.length} proceso${processes.length !== 1 ? 's' : ''}`
: `${processes.length} proceso${processes.length !== 1 ? 's' : ''} · ${orgCount} cliente${orgCount !== 1 ? 's' : ''}`
}
</p>
</div>
<div className="flex items-center gap-2">
@@ -888,6 +823,7 @@ export function LibraryPage() {
</button>
</div>
{!isClientRole && (
<button
onClick={() => navigate({ to: '/settings' })}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
@@ -895,9 +831,10 @@ export function LibraryPage() {
>
<Settings className="h-4 w-4" />
</button>
)}
{!isClientRole && (
<button
onClick={() => handleImport(view === 'group' && activeGroupId ? activeGroupId : undefined)}
onClick={handleImport}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
style={{ background: '#F59845' }}
>
@@ -915,7 +852,6 @@ export function LibraryPage() {
>
{view === 'home' ? (
<HomeView
onNavigateToGroup={navigateToGroup}
onImport={handleImport}
ownerFilter={ownerFilter}
currentUserId={currentUserId}

View 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
},
}

View File

@@ -13,6 +13,7 @@ function toRow(process: Process, userId: string) {
analysis_horizon_years: process.analysisHorizonYears,
automation_investment: process.automationInvestment,
group_id: process.groupId,
area_id: process.areaId,
tags: process.tags,
// owner_id se preserva del valor existente en el proceso; solo se inicializa con userId en creates
owner_id: process.ownerId || userId,
@@ -25,6 +26,8 @@ function toRow(process: Process, userId: string) {
function fromRow(row: Record<string, unknown>): Process {
// org puede estar presente si el query hizo JOIN con organizations (getById lo incluye)
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 {
id: row.id as string,
name: row.name as string,
@@ -43,6 +46,8 @@ function fromRow(row: Record<string, unknown>): Process {
updatedBy: (row.updated_by as string) ?? '',
orgId: (row.org_id as string | null) ?? 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,
tags: process.tags,
group_id: process.groupId,
area_id: process.areaId,
updated_by: userId,
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)
const { data, error } = await supabase
.from('processes')
.select('*, org:organizations!org_id(name)')
.select('*, org:organizations!org_id(name), area:areas!area_id(name)')
.eq('id', id)
.single()
if (error) return undefined
@@ -115,7 +121,7 @@ export const supabaseProcessRepo = {
async getAll(): Promise<ProcessWithUserNames[]> {
const { data, error } = await supabase
.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 })
if (error) throw error
return (data ?? []).map((row) => fromRowWithUsers(row as Record<string, unknown>))

View File

@@ -1,5 +1,5 @@
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 { ImportPage } from '@/features/import/ImportPage'
import { LibraryPage } from '@/features/library/LibraryPage'
@@ -131,6 +131,12 @@ const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/settings',
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({

View 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;

View File

@@ -229,7 +229,7 @@ describe('MethodologyFooter', () => {
id: 'p1', name: 'Proceso Test', clientName: '',
bpmnXml: '<def/>', currency: 'USD',
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()
})
@@ -239,7 +239,7 @@ describe('MethodologyFooter', () => {
id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD',
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} />)
const text = document.body.textContent ?? ''
@@ -251,7 +251,7 @@ describe('MethodologyFooter', () => {
id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD',
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} />)
const text = document.body.textContent ?? ''
@@ -342,7 +342,7 @@ describe('ReportPage — con datos completos', () => {
updatedAt: Date.now() - 1000 * 60 * 30,
groupId: null,
tags: [],
ownerId: 'test-user', updatedBy: 'test-user', orgId: null,
ownerId: 'test-user', updatedBy: 'test-user', orgId: null, areaId: null,
}
const mockSimulation = {

View File

@@ -600,7 +600,7 @@ const mockProcessBase = {
currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_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 = {

View File

@@ -14,7 +14,7 @@ function makeProcess(extras: Partial<Process> = {}): Process {
id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ',
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
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,
}
}

View File

@@ -9,7 +9,7 @@ function makeProcess(currency: string, extras: Partial<Process> = {}): Process {
id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC',
bpmnXml: '', currency, overheadPercentage: 0.2,
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,
}
}

View File

@@ -66,7 +66,7 @@ describe('Medición de tamaños de CSV — 3 sample BPMNs', () => {
bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 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 = {

View File

@@ -51,7 +51,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
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 = [

View File

@@ -43,7 +43,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 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

View File

@@ -54,7 +54,7 @@ const mockProcess: Process = {
overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 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 = {

View File

@@ -59,7 +59,7 @@ const mockProcess: Process = {
overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 50000,
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 = {

View File

@@ -56,7 +56,7 @@ const mockProcess: Process = {
overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
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 = {

View File

@@ -52,7 +52,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
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 = {

View File

@@ -52,7 +52,7 @@ const mockProcess: Process = {
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000,
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 = {

View 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')
})
})
})

View File

@@ -94,7 +94,7 @@ describe('simulation store — invalidación por cambio en process store', () =>
currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000,
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 })