# 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
