feat(library): agrupación org → área reemplaza groupId en HomeView

- Admin ve procesos agrupados por org (colapsable) → sub-secciones por área
- Búsqueda muestra chip con areaName ?? orgName en lugar de nombre de grupo
- Header muestra conteo de clientes (orgs) en vez de groups
- Botón Settings ⚙ oculto para client_editor y client_viewer
- Efecto orgParam/groupId scroll obsoleto eliminado
- handleImport simplificado (sin groupId)
- data-testid="groups-header" preservado en el nuevo contenedor admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 20:17:08 -03:00
parent b99cc75f80
commit cbf3a88e78

View File

@@ -1,15 +1,16 @@
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useMemo } from 'react'
import { useNavigate, useRouterState } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { import {
Upload, Search, Plus, Building2, FolderX, GitBranch, Upload, Search, Building2, GitBranch,
Clock, TrendingUp, BarChart2, FileX2, X, Settings, Clock, TrendingUp, BarChart2, FileX2, X, Settings,
MoreVertical, Trash2, Share2, Lock, User, MoreVertical, Trash2, Share2, Lock, User,
ChevronDown, FolderOpen,
} from 'lucide-react' } from 'lucide-react'
import { useLibraryStore } from '@/store/library-store' import { useLibraryStore } from '@/store/library-store'
import { useAuth } from '@/auth/AuthContext' import { useAuth } from '@/auth/AuthContext'
import { supabaseProcessRepo, type ProcessWithUserNames } from '@/persistence/supabase/process-repo' import { supabaseProcessRepo, type ProcessWithUserNames } from '@/persistence/supabase/process-repo'
import { formatCurrency, formatSimulationTimestamp } from '@/lib/format' import { formatCurrency, formatSimulationTimestamp } from '@/lib/format'
import type { Process, ProcessGroup, Simulation } from '@/domain/types' import type { Process, Simulation } from '@/domain/types'
import { AppHeader } from '@/components/AppHeader' import { AppHeader } from '@/components/AppHeader'
import { AppFooter } from '@/components/AppFooter' import { AppFooter } from '@/components/AppFooter'
@@ -267,129 +268,22 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete,
) )
} }
// ─── Group Card ───────────────────────────────────────────────────────────────
function GroupCard({ group, processCount, lastUpdated, onClick }: {
group: ProcessGroup
processCount: number
lastUpdated: number | null
onClick: () => void
}) {
return (
<button
className="text-left w-full rounded-xl p-3 transition-all"
style={{
background: 'hsl(var(--card))',
border: '0.5px solid hsl(var(--border))',
borderLeft: '3px solid #F59845',
borderRadius: '0.5rem',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = 'hsl(var(--border) / 0.8)'; (e.currentTarget as HTMLButtonElement).style.borderLeftColor = '#F59845' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = 'hsl(var(--border))'; (e.currentTarget as HTMLButtonElement).style.borderLeftColor = '#F59845' }}
onClick={onClick}
>
<p className="text-2xl font-medium" style={{ color: '#F59845' }}>{processCount}</p>
<p className="text-[13px] font-medium truncate mt-0.5">{group.name}</p>
{lastUpdated && (
<p className="text-[11px] text-muted-foreground mt-1">
Actualizado {relativeDate(lastUpdated)}
</p>
)}
</button>
)
}
// ─── New Group Card ───────────────────────────────────────────────────────────
function NewGroupCard({ label, onCreate }: { label: string; onCreate: (name: string) => void }) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const confirm = useCallback(() => {
const trimmed = value.trim()
if (trimmed) onCreate(trimmed)
setValue('')
setEditing(false)
}, [value, onCreate])
const cancel = useCallback(() => {
setValue('')
setEditing(false)
}, [])
useEffect(() => {
if (editing) inputRef.current?.focus()
}, [editing])
if (editing) {
return (
<div className="rounded-xl p-3 transition-all"
style={{ border: '1.5px solid #F59845', background: 'hsl(var(--card))', borderRadius: '0.5rem' }}>
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') confirm()
if (e.key === 'Escape') cancel()
}}
placeholder={`Nombre del ${label.toLowerCase()}...`}
className="w-full text-[12px] bg-transparent border-none outline-none placeholder:text-muted-foreground"
/>
<div className="flex gap-1.5 mt-2">
<button
onClick={confirm}
className="text-[11px] px-2 py-0.5 rounded font-medium text-white"
style={{ background: '#F59845' }}
>
Crear
</button>
<button
onClick={cancel}
className="text-[11px] px-2 py-0.5 rounded font-medium text-muted-foreground border border-border"
>
Cancelar
</button>
</div>
</div>
)
}
return (
<button
className="text-left w-full rounded-xl p-3 flex flex-col items-center justify-center gap-1.5 transition-all group"
style={{ border: '0.5px dashed hsl(var(--border))', background: 'hsl(var(--card))', borderRadius: '0.5rem', minHeight: '80px' }}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = '#F59845'; (e.currentTarget as HTMLButtonElement).style.color = '#F59845' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = 'hsl(var(--border))'; (e.currentTarget as HTMLButtonElement).style.color = '' }}
onClick={() => setEditing(true)}
>
<Plus className="h-4 w-4 text-muted-foreground group-hover:text-[#F59845] transition-colors" />
<span className="text-[12px] text-muted-foreground group-hover:text-[#F59845] transition-colors">
Nuevo {label.toLowerCase()}
</span>
</button>
)
}
// ─── Home View ──────────────────────────────────────────────────────────────── // ─── Home View ────────────────────────────────────────────────────────────────
function HomeView({ function HomeView({
onNavigateToGroup,
onImport, onImport,
ownerFilter, ownerFilter,
currentUserId, currentUserId,
currentUserName, currentUserName,
onShared, onShared,
}: { }: {
onNavigateToGroup: (groupId: string | null) => void onImport: () => void
onImport: (groupId?: string) => void
ownerFilter: 'all' | 'mine' ownerFilter: 'all' | 'mine'
currentUserId: string currentUserId: string
currentUserName: string currentUserName: string
onShared: () => void onShared: () => void
}) { }) {
const { groups, processes, settings, createGroup, isLoading, error, load } = useLibraryStore() const { groups, processes, isLoading, error, load } = useLibraryStore()
const { user } = useAuth() const { user } = useAuth()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [activeTags, setActiveTags] = useState<string[]>([]) const [activeTags, setActiveTags] = useState<string[]>([])
@@ -399,10 +293,46 @@ function HomeView({
: processes : processes
const cloud = tagCloudData(visibleProcesses) const cloud = tagCloudData(visibleProcesses)
const unclassifiedProcesses = visibleProcesses.filter((p) => p.groupId == null)
const isSearching = search.trim().length > 0 || activeTags.length > 0 const isSearching = search.trim().length > 0 || activeTags.length > 0
const [collapsedOrgs, setCollapsedOrgs] = useState<Set<string>>(new Set())
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])
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)
}
return [...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)
})
}
function toggleOrg(orgId: string) {
setCollapsedOrgs(prev => {
const next = new Set(prev)
if (next.has(orgId)) next.delete(orgId)
else next.add(orgId)
return next
})
}
const filteredProcesses = isSearching const filteredProcesses = isSearching
? visibleProcesses.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags)) ? visibleProcesses.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
: [] : []
@@ -522,26 +452,23 @@ function HomeView({
{filteredProcesses.length === 0 && ( {filteredProcesses.length === 0 && (
<p className="text-[13px] text-muted-foreground py-4 text-center">Sin resultados para esa búsqueda.</p> <p className="text-[13px] text-muted-foreground py-4 text-center">Sin resultados para esa búsqueda.</p>
)} )}
{filteredProcesses.map((p) => { {filteredProcesses.map((p) => (
const group = groups.find((g) => g.id === p.groupId) <ProcessCardWithSim
return ( key={p.id}
<ProcessCardWithSim process={p}
key={p.id} showGroupChip
process={p} groupName={p.areaName ?? p.orgName ?? undefined}
showGroupChip currentUserId={currentUserId}
groupName={group?.name} currentUserName={currentUserName}
currentUserId={currentUserId} onShared={onShared}
currentUserName={currentUserName} />
onShared={onShared} ))}
/>
)
})}
</div> </div>
)} )}
{/* Vista normal — grupos (solo roles internos) */} {/* Vista normal — org → área (solo roles internos) */}
{!isSearching && !isClientRole && ( {!isSearching && !isClientRole && (
<> <div className="space-y-3" data-testid="groups-header">
{/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */} {/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */}
{cloud.length >= 3 && ( {cloud.length >= 3 && (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -575,59 +502,89 @@ function HomeView({
</div> </div>
)} )}
{/* Header sección grupos */} {/* Grupos org → área */}
<div className="flex items-center gap-2" data-testid="groups-header"> {orgGroups.map(({ orgId, orgName, processes: orgProcesses }) => {
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground"> const isCollapsed = collapsedOrgs.has(orgId)
{settings.groupLabel}s const areaGroups = getAreaGroups(orgProcesses)
</span>
<span className="text-[10px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground">
{groups.length}
</span>
</div>
{/* Grid de grupos */} return (
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}> <div key={orgId} className="border border-border rounded-xl overflow-hidden">
{groups.map((group) => { {/* Header de org — colapsable */}
const groupProcesses = visibleProcesses.filter((p) => p.groupId === group.id) <button
const lastUpdated = groupProcesses.length > 0 onClick={() => toggleOrg(orgId)}
? Math.max(...groupProcesses.map((p) => p.updatedAt)) className="w-full flex items-center justify-between px-4 py-3 bg-secondary/40 hover:bg-secondary/60 transition-colors"
: null >
return ( <div className="flex items-center gap-2">
<div key={group.id} id={`group-${group.id}`}> <Building2 className="h-4 w-4 text-muted-foreground" />
<GroupCard <span className="text-[13px] font-medium">{orgName}</span>
group={group} <span className="text-[11px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground">
processCount={groupProcesses.length} {orgProcesses.length}
lastUpdated={lastUpdated} </span>
onClick={() => onNavigateToGroup(group.id)} </div>
<ChevronDown
className="h-4 w-4 text-muted-foreground transition-transform duration-200"
style={{ transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
/> />
</div> </button>
)
})}
<NewGroupCard
label={settings.groupLabel}
onCreate={(name) => createGroup(name, user!.id)}
/>
</div>
{/* Sin clasificar */} {/* Contenido — animado */}
{unclassifiedProcesses.length > 0 && ( <div
<button className="transition-all duration-200 overflow-hidden"
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all hover:border-border/80" style={{ maxHeight: isCollapsed ? 0 : '9999px', opacity: isCollapsed ? 0 : 1 }}
style={{ border: '0.5px dashed hsl(var(--border))', background: 'hsl(var(--card))', borderRadius: '0.5rem' }} >
onClick={() => onNavigateToGroup(null)} <div className="p-3 space-y-3">
> {areaGroups.map(({ areaId, areaName, processes: areaProcesses }) => (
<FolderX className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <div key={areaId}>
<div className="flex-1 min-w-0"> {/* Sub-header de área: visible si hay >1 área o si el área tiene nombre */}
<span className="text-[13px] font-medium text-secondary-foreground"> {(areaGroups.length > 1 || areaId !== '__sin_area') && (
Procesos sin {settings.groupLabel.toLowerCase()} asignado <p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-2 flex items-center gap-1.5">
</span> <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> </div>
<span className="text-[11px] text-muted-foreground flex-shrink-0"> )
{unclassifiedProcesses.length} proceso{unclassifiedProcesses.length !== 1 ? 's' : ''} })}
</span>
</button> {/* 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>
)} )}
{/* Vista cliente: lista plana de procesos (sin grupos, RLS garantiza que solo ven los suyos) */} {/* Vista cliente: lista plana de procesos (sin grupos, RLS garantiza que solo ven los suyos) */}
@@ -774,10 +731,8 @@ function GroupView({
export function LibraryPage() { export function LibraryPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { load, processes, groups, settings, isLoading } = useLibraryStore() const { load, processes } = useLibraryStore()
const { user, isProfileLoaded } = useAuth() const { user, isProfileLoaded } = useAuth()
const searchStr = useRouterState({ select: (s) => s.location.search })
const orgParam = new URLSearchParams(searchStr).get('org')
const [view, setView] = useState<'home' | 'group'>('home') const [view, setView] = useState<'home' | 'group'>('home')
const [activeGroupId, setActiveGroupId] = useState<string | null>(null) const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
const [transitioning, setTransitioning] = useState(false) const [transitioning, setTransitioning] = useState(false)
@@ -789,25 +744,6 @@ export function LibraryPage() {
load() load()
}, [load, user?.id]) }, [load, user?.id])
// Cuando la biblioteca cargó con ?org=<uuid>, hacer scroll al grupo que contiene ese org
useEffect(() => {
if (!orgParam || isLoading || processes.length === 0) return
const proc = processes.find((p) => p.orgId === orgParam)
if (!proc?.groupId) return
requestAnimationFrame(() => {
document.getElementById(`group-${proc.groupId}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}, [orgParam, isLoading, processes])
function navigateToGroup(groupId: string | null) {
setTransitioning(true)
setTimeout(() => {
setActiveGroupId(groupId)
setView('group')
setTransitioning(false)
}, 150)
}
function navigateHome() { function navigateHome() {
setTransitioning(true) setTransitioning(true)
setTimeout(() => { setTimeout(() => {
@@ -817,12 +753,8 @@ export function LibraryPage() {
}, 150) }, 150)
} }
function handleImport(groupId?: string) { function handleImport() {
if (groupId) { navigate({ to: '/import' })
navigate({ to: '/import', search: { groupId } })
} else {
navigate({ to: '/import' })
}
} }
// Esperar a que syncProfileFromDB complete antes de renderizar UI rol-dependiente. // Esperar a que syncProfileFromDB complete antes de renderizar UI rol-dependiente.
@@ -848,8 +780,8 @@ export function LibraryPage() {
const currentUserId = user.id const currentUserId = user.id
const currentUserName = user.name const currentUserName = user.name
const totalGroups = groups.length
const isClientRole = user.platformRole === 'client_editor' || user.platformRole === 'client_viewer' const isClientRole = user.platformRole === 'client_editor' || user.platformRole === 'client_viewer'
const orgCount = new Set(processes.map(p => p.orgId).filter(Boolean)).size
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@@ -860,7 +792,10 @@ export function LibraryPage() {
<div> <div>
<h1 className="text-[18px] font-medium">Biblioteca de procesos</h1> <h1 className="text-[18px] font-medium">Biblioteca de procesos</h1>
<p className="text-[12px] text-muted-foreground mt-0.5"> <p className="text-[12px] text-muted-foreground mt-0.5">
{processes.length} proceso{processes.length !== 1 ? 's' : ''} · {totalGroups} {settings.groupLabel.toLowerCase()}{totalGroups !== 1 ? 's' : ''} {isClientRole
? `${processes.length} proceso${processes.length !== 1 ? 's' : ''}`
: `${processes.length} proceso${processes.length !== 1 ? 's' : ''} · ${orgCount} cliente${orgCount !== 1 ? 's' : ''}`
}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -888,16 +823,18 @@ export function LibraryPage() {
</button> </button>
</div> </div>
<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>
{!isClientRole && ( {!isClientRole && (
<button <button
onClick={() => handleImport(view === 'group' && activeGroupId ? activeGroupId : undefined)} 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>
)}
{!isClientRole && (
<button
onClick={handleImport}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90" className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
style={{ background: '#F59845' }} style={{ background: '#F59845' }}
> >
@@ -915,7 +852,6 @@ export function LibraryPage() {
> >
{view === 'home' ? ( {view === 'home' ? (
<HomeView <HomeView
onNavigateToGroup={navigateToGroup}
onImport={handleImport} onImport={handleImport}
ownerFilter={ownerFilter} ownerFilter={ownerFilter}
currentUserId={currentUserId} currentUserId={currentUserId}