# Sprint 8 — Etapa 3: LibraryPage — agrupación org → área > **Tipo:** UI — refactorización de vista de biblioteca > **Rama:** main > **Estimación:** 2-3 horas > **Dependencias:** Etapas 1 y 2 completadas ✅ --- ## Objetivo Reemplazar la agrupación por `process_groups` / `groupId` en LibraryPage con una agrupación real por `organizations → areas`. El resultado visual: el admin ve sus procesos organizados por cliente (org) con sub-secciones por área, colapsables. Los roles cliente no cambian (ya tienen lista plana). --- ## Contexto del código actual Leer en su totalidad antes de modificar: - `src/features/library/LibraryPage.tsx` (942 líneas) - `src/persistence/supabase/process-repo.ts` (para el cambio en `getAll()`) - `src/router.tsx` (para el guard de `/settings`) **Estructura actual relevante de LibraryPage:** - `HomeView` para admin: grid de `GroupCard` (desde `process_groups` via `library-store.groups`) + sección "Sin clasificar" filtrada por `groupId == null` - `HomeView` para cliente: lista plana de procesos (sin cambios en esta etapa) - `GroupView`: lista de procesos filtrada por `groupId` — se preserva como código muerto (no eliminar) - `LibraryPage`: botón Settings ⚙ visible para todos los roles (línea ~891) - `LibraryPage`: efecto `orgParam` que hace scroll a `group-${proc.groupId}` (líneas ~793-800) **Dato importante:** `getAll()` en `process-repo.ts` actualmente NO hace JOIN con org ni area, por lo que los procesos retornados tienen `orgName: null` y `areaName: null`. Esta etapa extiende ese JOIN como primer paso. --- ## Alcance ### Cambio 1: Extender `getAll()` en `process-repo.ts` Agregar org y area al select de `getAll()`. Cambio de una línea: ```typescript // Antes: .select('*, owner:users!owner_id(name, avatar_url), updater:users!updated_by(name)') // Después: .select('*, owner:users!owner_id(name, avatar_url), updater:users!updated_by(name), org:organizations!org_id(name), area:areas!area_id(name)') ``` `fromRow()` ya sabe extraer `orgName` y `areaName` desde los JOINs (implementado en Etapa 2). Los tests existentes siguen pasando porque `fromRow()` usa null-safe extraction (`org?.name ?? null`). ### Cambio 2: Refactorizar `HomeView` — sección admin **Reemplazar** el bloque `{!isSearching && !isClientRole && (...)}` (líneas 543-631 aproximadamente) por una lista agrupada org → área. **Nueva estructura visual para admin:** ``` [Org: Solar Banco] ← header colapsable con ícono Building2 y badge de conteo [Área: Operaciones] ← sub-header con ícono FolderOpen [ProcessCard] [ProcessCard] [Área: RR.HH.] [ProcessCard] [Sin área] ← solo si existen procesos sin area_id [ProcessCard] [Org: InQuality] [Sin área] [ProcessCard] ``` **Lógica de agrupación** (computar desde `visibleProcesses`): ```typescript // Agrupar por orgId → areaId const orgGroups = useMemo(() => { const byOrg = new Map() for (const p of visibleProcesses) { const key = p.orgId ?? '__sin_org' const orgName = p.orgName ?? 'Sin organización' if (!byOrg.has(key)) { byOrg.set(key, { orgId: key, orgName, processes: [] }) } byOrg.get(key)!.processes.push(p) } return [...byOrg.values()].sort((a, b) => a.orgName.localeCompare(b.orgName)) }, [visibleProcesses]) // Dentro de cada org, agrupar por areaId function getAreaGroups(orgProcesses: ProcessWithUserNames[]) { const byArea = new Map() for (const p of orgProcesses) { const key = p.areaId ?? '__sin_area' const areaName = p.areaName ?? 'Sin área' if (!byArea.has(key)) { byArea.set(key, { areaId: key, areaName, processes: [] }) } byArea.get(key)!.processes.push(p) } // "Sin área" siempre al final const sorted = [...byArea.values()].sort((a, b) => { if (a.areaId === '__sin_area') return 1 if (b.areaId === '__sin_area') return -1 return a.areaName.localeCompare(b.areaName) }) return sorted } ``` **Estado de colapso por org** (criterio de wow): ```typescript const [collapsedOrgs, setCollapsedOrgs] = useState>(new Set()) function toggleOrg(orgId: string) { setCollapsedOrgs(prev => { const next = new Set(prev) if (next.has(orgId)) next.delete(orgId) else next.add(orgId) return next }) } ``` **Renderizado del bloque admin:** ```tsx {!isSearching && !isClientRole && (
{/* Tag cloud — mantener igual que antes */} {cloud.length >= 3 && (...)} {/* Grupos org → área */} {orgGroups.map(({ orgId, orgName, processes: orgProcesses }) => { const isCollapsed = collapsedOrgs.has(orgId) const areaGroups = getAreaGroups(orgProcesses) return (
{/* Header de org — colapsable */} {/* Contenido — animado */}
{areaGroups.map(({ areaId, areaName, processes: areaProcesses }) => (
{/* Sub-header de área (solo si hay más de un área o existe un área nombrada) */} {(areaGroups.length > 1 || areaId !== '__sin_area') && (

{areaName} · {areaProcesses.length}

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

Tu biblioteca está vacía

Importá tu primer BPMN para empezar

)}
)} ``` **Agregar `ChevronDown` y `FolderOpen` a los imports de lucide-react** en el archivo. Verificar que no estén ya importados. ### Cambio 3: Actualizar resultados de búsqueda En el bloque de `filteredProcesses.map()` (búsqueda activa), reemplazar la resolución del nombre de grupo por area/org: ```typescript // Antes: const group = groups.find((g) => g.id === p.groupId) // Después: ``` ### Cambio 4: Actualizar header de LibraryPage La línea que muestra `{totalGroups} {settings.groupLabel.toLowerCase()}s` no aplica al nuevo modelo. Reemplazar por conteo de orgs: ```typescript // Antes: {processes.length} proceso{...} · {totalGroups} {settings.groupLabel.toLowerCase()}{...} // Después (para admin): {processes.length} proceso{processes.length !== 1 ? 's' : ''} · {orgGroups.length} cliente{orgGroups.length !== 1 ? 's' : ''} // Para cliente: mostrar solo procesos (sin conteo de org) {processes.length} proceso{processes.length !== 1 ? 's' : ''} ``` Nota: `orgGroups` se computa en `HomeView`, no en `LibraryPage`. Para el header en `LibraryPage`, usar un conteo simple: ```typescript const orgCount = new Set(processes.map(p => p.orgId).filter(Boolean)).size ``` Y en el JSX del header: ```typescript {isClientRole ? `${processes.length} proceso${processes.length !== 1 ? 's' : ''}` : `${processes.length} proceso${processes.length !== 1 ? 's' : ''} · ${orgCount} cliente${orgCount !== 1 ? 's' : ''}` } ``` ### Cambio 5: Ocultar botón Settings para roles cliente En `LibraryPage` (líneas ~891-897), envolver el botón de Settings: ```tsx // Antes: el botón de Settings está suelto, visible para todos // Después: solo visible para admin/member {!isClientRole && ( )} ``` ### Cambio 6: Guard de ruta `/settings` en `router.tsx` Agregar `beforeLoad` a `settingsRoute` para bloquear acceso de roles cliente: ```typescript // En router.tsx, actualmente: const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage, }) // Nuevo: const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage, beforeLoad: ({ context }: { context: RouterContext }) => { const role = (context as { auth?: { user?: { platformRole?: string } } }).auth?.user?.platformRole if (role === 'client_editor' || role === 'client_viewer') { throw redirect({ to: '/library' }) } }, }) ``` Verificar primero cómo está tipado `context` en los guards existentes del router (por ejemplo en `adminLayoutRoute`) y usar el mismo patrón. ### Cambio 7: Eliminar el efecto `orgParam` obsoleto Las líneas ~793-800: ```typescript useEffect(() => { if (!orgParam || isLoading || processes.length === 0) return const proc = processes.find((p) => p.orgId === orgParam) if (!proc?.groupId) return // ← depende de groupId, ya no válido requestAnimationFrame(() => { document.getElementById(`group-${proc.groupId}`)?.scrollIntoView(...) }) }, [orgParam, isLoading, processes]) ``` Este efecto busca un proceso por `orgId` y hace scroll a `group-${proc.groupId}` — un ID que ya no existe en el nuevo DOM. Eliminar el efecto completo. La variable `orgParam` puede eliminarse también si ya no se usa en ningún otro lugar. ### Cambio 8: Simplificar `handleImport` ```typescript // Antes: function handleImport(groupId?: string) { if (groupId) { navigate({ to: '/import', search: { groupId } }) } else { navigate({ to: '/import' }) } } // Después: function handleImport() { navigate({ to: '/import' }) } ``` Y actualizar todas las llamadas a `handleImport` para no pasar argumentos. --- ## Lo que NO cambia en esta etapa - **`GroupView`**: mantener el componente intacto — se convierte en código no alcanzable desde el admin, pero no eliminar (evita riesgo de romper algo) - **`library-store.ts`**: sin cambios — sigue cargando `groups` para la Settings page - **Vista cliente de `HomeView`**: sin cambios (lista plana, funciona igual) - **`group-repo.ts`**: sin cambios - **`WorkspacePage.tsx`**: sin cambios (Etapa 4) - **`GlobalSettingsPanel.tsx`**: sin cambios (Etapa 4) - **`ProcessGroup` interface en types.ts**: sin cambios - **Props `showGroupChip` / `groupName` de `ProcessCard`**: adaptar pero no eliminar --- ## Criterios de validación ### Funcionales (validación visual mandatoria) - [ ] Admin ve procesos agrupados por org → área en la biblioteca - [ ] Hacer click en header de org colapsa/expande la sección con animación - [ ] "Sin área" aparece al final de cada org (si existen procesos sin área) - [ ] Roles cliente siguen viendo lista plana (sin cambio) - [ ] Botón Settings ⚙ no visible para client_editor y client_viewer - [ ] Navegar a `/settings` como client_editor o client_viewer redirige a `/library` - [ ] Búsqueda muestra chip con nombre de área u org (en lugar de nombre de grupo) - [ ] El botón "Importar BPMN" funciona y lleva a `/import` ### Técnicos - [ ] `npm run build` sin errores TypeScript - [ ] `npm run test` sin regresiones (596 tests actuales) - [ ] `getAll()` ahora incluye org y area en el select — verificar en Network tab que el request lleva el select extendido - [ ] `orgParam` y el efecto de scroll obsoleto eliminados --- ## Restricciones explícitas - **NO eliminar** `GroupView` — preservar aunque sea código no alcanzable - **NO modificar** `library-store.ts`, `group-repo.ts` ni la Settings page - **NO eliminar** props `showGroupChip`/`groupName` de `ProcessCard` — solo actualizarlos - **NO tocar** `WorkspacePage.tsx` ni `GlobalSettingsPanel.tsx` — Etapa 4 - **NO tocar** `clientName` ni `groupId` en el dominio — Etapas posteriores - **NO eliminar** los datos de `groups` del store — la Settings page los sigue usando --- ## Archivos autorizados a modificar ``` src/ ├── persistence/supabase/ │ └── process-repo.ts ← 1 línea: extender select de getAll() ├── features/library/ │ └── LibraryPage.tsx ← refactorización principal └── router.tsx ← guard beforeLoad en settingsRoute ``` --- ## Commits esperados ``` feat(library): agrupación org → área reemplaza groupId en HomeView feat(router): guard beforeLoad en /settings para client_editor y client_viewer fix(process-repo): getAll() incluye JOIN con org y area para populear names ``` --- ## Reporte esperado 1. Descripción de cómo quedó el nuevo bloque admin de `HomeView` (estructura visual) 2. Confirmar que `GroupView` sigue en el archivo (código no alcanzable, no eliminado) 3. Lista de archivos modificados con diff compacto 4. `npm run build` y `npm run test` (con conteo) 5. **Solicitar validación visual del director** antes de declarar OK — mostrar descripción del estado de la UI para que el director valide en la app