15 KiB
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 engetAll())src/router.tsx(para el guard de/settings)
Estructura actual relevante de LibraryPage:
HomeViewpara admin: grid deGroupCard(desdeprocess_groupsvialibrary-store.groups) + sección "Sin clasificar" filtrada porgroupId == nullHomeViewpara cliente: lista plana de procesos (sin cambios en esta etapa)GroupView: lista de procesos filtrada porgroupId— se preserva como código muerto (no eliminar)LibraryPage: botón Settings ⚙ visible para todos los roles (línea ~891)LibraryPage: efectoorgParamque hace scroll agroup-${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:
// 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):
// 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):
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:
{!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:
// 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:
// 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:
const orgCount = new Set(processes.map(p => p.orgId).filter(Boolean)).size
Y en el JSX del header:
{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:
// 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:
// 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:
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
// 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 cargandogroupspara la Settings page- Vista cliente de
HomeView: sin cambios (lista plana, funciona igual) group-repo.ts: sin cambiosWorkspacePage.tsx: sin cambios (Etapa 4)GlobalSettingsPanel.tsx: sin cambios (Etapa 4)ProcessGroupinterface en types.ts: sin cambios- Props
showGroupChip/groupNamedeProcessCard: 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
/settingscomo 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 buildsin errores TypeScriptnpm run testsin regresiones (596 tests actuales)getAll()ahora incluye org y area en el select — verificar en Network tab que el request lleva el select extendidoorgParamy 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.tsni la Settings page - NO eliminar props
showGroupChip/groupNamedeProcessCard— solo actualizarlos - NO tocar
WorkspacePage.tsxniGlobalSettingsPanel.tsx— Etapa 4 - NO tocar
clientNamenigroupIden el dominio — Etapas posteriores - NO eliminar los datos de
groupsdel 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
- Descripción de cómo quedó el nuevo bloque admin de
HomeView(estructura visual) - Confirmar que
GroupViewsigue en el archivo (código no alcanzable, no eliminado) - Lista de archivos modificados con diff compacto
npm run buildynpm run test(con conteo)- 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