433 lines
15 KiB
Markdown
433 lines
15 KiB
Markdown
# 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
|