Files
inq-roi-simulador-web/sprints/sprint-8/ETAPA_3_PROMPT.md
Marcos Benítez de00e3a902
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
etapa 3 terminada
2026-07-07 20:19:45 -03:00

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