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:
@@ -1,15 +1,16 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Upload, Search, Plus, Building2, FolderX, GitBranch,
|
||||
Upload, Search, Building2, GitBranch,
|
||||
Clock, TrendingUp, BarChart2, FileX2, X, Settings,
|
||||
MoreVertical, Trash2, Share2, Lock, User,
|
||||
ChevronDown, FolderOpen,
|
||||
} from 'lucide-react'
|
||||
import { useLibraryStore } from '@/store/library-store'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
import { supabaseProcessRepo, type ProcessWithUserNames } from '@/persistence/supabase/process-repo'
|
||||
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 { 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function HomeView({
|
||||
onNavigateToGroup,
|
||||
onImport,
|
||||
ownerFilter,
|
||||
currentUserId,
|
||||
currentUserName,
|
||||
onShared,
|
||||
}: {
|
||||
onNavigateToGroup: (groupId: string | null) => void
|
||||
onImport: (groupId?: string) => void
|
||||
onImport: () => void
|
||||
ownerFilter: 'all' | 'mine'
|
||||
currentUserId: string
|
||||
currentUserName: string
|
||||
onShared: () => void
|
||||
}) {
|
||||
const { groups, processes, settings, createGroup, isLoading, error, load } = useLibraryStore()
|
||||
const { groups, processes, isLoading, error, load } = useLibraryStore()
|
||||
const { user } = useAuth()
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeTags, setActiveTags] = useState<string[]>([])
|
||||
@@ -399,10 +293,46 @@ function HomeView({
|
||||
: processes
|
||||
|
||||
const cloud = tagCloudData(visibleProcesses)
|
||||
const unclassifiedProcesses = visibleProcesses.filter((p) => p.groupId == null)
|
||||
|
||||
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
|
||||
? visibleProcesses.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
|
||||
: []
|
||||
@@ -522,26 +452,23 @@ function HomeView({
|
||||
{filteredProcesses.length === 0 && (
|
||||
<p className="text-[13px] text-muted-foreground py-4 text-center">Sin resultados para esa búsqueda.</p>
|
||||
)}
|
||||
{filteredProcesses.map((p) => {
|
||||
const group = groups.find((g) => g.id === p.groupId)
|
||||
return (
|
||||
{filteredProcesses.map((p) => (
|
||||
<ProcessCardWithSim
|
||||
key={p.id}
|
||||
process={p}
|
||||
showGroupChip
|
||||
groupName={group?.name}
|
||||
groupName={p.areaName ?? p.orgName ?? undefined}
|
||||
currentUserId={currentUserId}
|
||||
currentUserName={currentUserName}
|
||||
onShared={onShared}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vista normal — grupos (solo roles internos) */}
|
||||
{/* Vista normal — org → área (solo roles internos) */}
|
||||
{!isSearching && !isClientRole && (
|
||||
<>
|
||||
<div className="space-y-3" data-testid="groups-header">
|
||||
{/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */}
|
||||
{cloud.length >= 3 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -575,59 +502,89 @@ function HomeView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header sección grupos */}
|
||||
<div className="flex items-center gap-2" data-testid="groups-header">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{settings.groupLabel}s
|
||||
</span>
|
||||
<span className="text-[10px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground">
|
||||
{groups.length}
|
||||
{/* 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>
|
||||
|
||||
{/* Grid de grupos */}
|
||||
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
|
||||
{groups.map((group) => {
|
||||
const groupProcesses = visibleProcesses.filter((p) => p.groupId === group.id)
|
||||
const lastUpdated = groupProcesses.length > 0
|
||||
? Math.max(...groupProcesses.map((p) => p.updatedAt))
|
||||
: null
|
||||
return (
|
||||
<div key={group.id} id={`group-${group.id}`}>
|
||||
<GroupCard
|
||||
group={group}
|
||||
processCount={groupProcesses.length}
|
||||
lastUpdated={lastUpdated}
|
||||
onClick={() => onNavigateToGroup(group.id)}
|
||||
<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: visible si hay >1 área o si el área tiene nombre */}
|
||||
{(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>
|
||||
)
|
||||
})}
|
||||
<NewGroupCard
|
||||
label={settings.groupLabel}
|
||||
onCreate={(name) => createGroup(name, user!.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sin clasificar */}
|
||||
{unclassifiedProcesses.length > 0 && (
|
||||
{/* 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
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all hover:border-border/80"
|
||||
style={{ border: '0.5px dashed hsl(var(--border))', background: 'hsl(var(--card))', borderRadius: '0.5rem' }}
|
||||
onClick={() => onNavigateToGroup(null)}
|
||||
onClick={() => onImport()}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: '#F59845' }}
|
||||
>
|
||||
<FolderX className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[13px] font-medium text-secondary-foreground">
|
||||
Procesos sin {settings.groupLabel.toLowerCase()} asignado
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground flex-shrink-0">
|
||||
{unclassifiedProcesses.length} proceso{unclassifiedProcesses.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<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) */}
|
||||
@@ -774,10 +731,8 @@ function GroupView({
|
||||
|
||||
export function LibraryPage() {
|
||||
const navigate = useNavigate()
|
||||
const { load, processes, groups, settings, isLoading } = useLibraryStore()
|
||||
const { load, processes } = useLibraryStore()
|
||||
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 [activeGroupId, setActiveGroupId] = useState<string | null>(null)
|
||||
const [transitioning, setTransitioning] = useState(false)
|
||||
@@ -789,25 +744,6 @@ export function LibraryPage() {
|
||||
load()
|
||||
}, [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() {
|
||||
setTransitioning(true)
|
||||
setTimeout(() => {
|
||||
@@ -817,13 +753,9 @@ export function LibraryPage() {
|
||||
}, 150)
|
||||
}
|
||||
|
||||
function handleImport(groupId?: string) {
|
||||
if (groupId) {
|
||||
navigate({ to: '/import', search: { groupId } })
|
||||
} else {
|
||||
function handleImport() {
|
||||
navigate({ to: '/import' })
|
||||
}
|
||||
}
|
||||
|
||||
// Esperar a que syncProfileFromDB complete antes de renderizar UI rol-dependiente.
|
||||
// Evita el flash donde 'guest' se muestra como platform_admin durante la ventana de sync.
|
||||
@@ -848,8 +780,8 @@ export function LibraryPage() {
|
||||
|
||||
const currentUserId = user.id
|
||||
const currentUserName = user.name
|
||||
const totalGroups = groups.length
|
||||
const isClientRole = user.platformRole === 'client_editor' || user.platformRole === 'client_viewer'
|
||||
const orgCount = new Set(processes.map(p => p.orgId).filter(Boolean)).size
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@@ -860,7 +792,10 @@ export function LibraryPage() {
|
||||
<div>
|
||||
<h1 className="text-[18px] font-medium">Biblioteca de procesos</h1>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -888,6 +823,7 @@ export function LibraryPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isClientRole && (
|
||||
<button
|
||||
onClick={() => navigate({ to: '/settings' })}
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||
@@ -895,9 +831,10 @@ export function LibraryPage() {
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{!isClientRole && (
|
||||
<button
|
||||
onClick={() => handleImport(view === 'group' && activeGroupId ? activeGroupId : undefined)}
|
||||
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"
|
||||
style={{ background: '#F59845' }}
|
||||
>
|
||||
@@ -915,7 +852,6 @@ export function LibraryPage() {
|
||||
>
|
||||
{view === 'home' ? (
|
||||
<HomeView
|
||||
onNavigateToGroup={navigateToGroup}
|
||||
onImport={handleImport}
|
||||
ownerFilter={ownerFilter}
|
||||
currentUserId={currentUserId}
|
||||
|
||||
Reference in New Issue
Block a user