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

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 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:

// 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 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