Files
PecHub/frontend/src/components/Layout/Sidebar.tsx
T
2026-03-19 18:06:44 +01:00

763 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Sidebar navigazione principale di PEChub.
*
* Struttura visiva (sidebar espansa):
* ┌────────────────────────────────┐
* │ [PF] PEChub [◀] │
* ├────────────────────────────────┤
* │ TUTTE LE CASELLE │
* │ 📥 Posta in Arrivo [badge] │
* │ 📤 Posta Inviata │
* │ ⭐ Preferiti │
* │ 📦 Archiviati │
* ├────────────────────────────────┤
* │ LE TUE CASELLE │
* │ ● gmgspa@pec.it [▼] │
* │ ├ 📥 In Arrivo │
* │ ├ 📤 Inviata │
* │ ├ ⭐ Preferiti │
* │ └ 📦 Archiviati │
* ├────────────────────────────────┤
* │ ✉ Nuova PEC │
* ├────────────────────────────────┤
* │ AMMINISTRAZIONE │
* │ 📬 Caselle PEC │
* │ 👥 Utenti │
* │ 🛡 Permessi │
* ├────────────────────────────────┤
* │ [avatar] Nome utente │
* │ Impostazioni | Esci │
* └────────────────────────────────┘
*
* Quando collassata (w-16) mostra solo icone/avatar con tooltip.
*/
import { NavLink } from 'react-router-dom'
import {
Inbox,
Send,
MailCheck,
Users,
Settings,
LogOut,
ChevronLeft,
ChevronRight,
Shield,
ChevronDown,
Filter,
Bell,
Star,
Archive,
Building2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
import { useInboxStore } from '@/store/inbox.store'
import { useState } from 'react'
import toast from 'react-hot-toast'
import { useQuery } from '@tanstack/react-query'
import { mailboxesApi } from '@/api/mailboxes.api'
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
import type { MailboxResponse, VirtualBoxResponse } from '@/types/api.types'
// ─── Sidebar principale ───────────────────────────────────────────────────────
export function Sidebar() {
const [collapsed, setCollapsed] = useState(false)
/**
* Set degli ID casella che l'utente ha esplicitamente chiuso.
* Tutte le caselle sono espanse per default (nessuno nell'insieme).
*/
const [collapsedMailboxes, setCollapsedMailboxes] = useState<Set<string>>(new Set())
/** Set degli ID virtual box che l'utente ha esplicitamente chiuso. */
const [collapsedVboxes, setCollapsedVboxes] = useState<Set<string>>(new Set())
const { user, isAdmin, isSuperAdmin, logout } = useAuth()
const unreadCount = useInboxStore((s) => s.unreadCount)
// Le caselle PEC vengono caricate qui e condivise via React Query cache
const { data: mailboxesData } = useQuery({
queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(),
staleTime: 5 * 60 * 1000,
})
const mailboxes = mailboxesData?.items ?? []
// Virtual Box assegnate all'utente corrente
const { data: myVboxes = [] } = useQuery({
queryKey: ['virtual-boxes', 'my'],
queryFn: () => virtualBoxesApi.myVirtualBoxes(),
staleTime: 5 * 60 * 1000,
})
const isMailboxExpanded = (id: string) => !collapsedMailboxes.has(id)
const toggleMailbox = (id: string) => {
setCollapsedMailboxes((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const isVboxExpanded = (id: string) => !collapsedVboxes.has(id)
const toggleVbox = (id: string) => {
setCollapsedVboxes((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const handleLogout = async () => {
try {
await logout()
toast.success('Disconnessione effettuata')
} catch {
toast.error('Errore durante il logout')
}
}
return (
<aside
className={cn(
'flex flex-col h-screen bg-gray-900 text-white transition-all duration-300 flex-shrink-0',
collapsed ? 'w-16' : 'w-64',
)}
>
{/* ── Logo + pulsante collassa ── */}
<div className="flex items-center justify-between p-4 border-b border-gray-700">
{!collapsed && (
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-blue-500 flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
PF
</div>
<span className="font-bold text-lg">PEChub</span>
</div>
)}
{collapsed && (
<div className="mx-auto h-8 w-8 rounded-lg bg-blue-500 flex items-center justify-center text-white font-bold text-sm">
PF
</div>
)}
<button
onClick={() => setCollapsed((c) => !c)}
className={cn(
'p-1 rounded hover:bg-gray-700 transition-colors text-gray-400',
collapsed && 'mx-auto mt-0',
)}
title={collapsed ? 'Espandi' : 'Comprimi'}
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</button>
</div>
{/* ── Navigazione principale ── */}
<nav className="flex-1 overflow-y-auto py-4 space-y-4">
{/* ── Sezione: Tutte le caselle ── */}
<div>
{!collapsed && (
<p className="px-4 mb-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Tutte le caselle
</p>
)}
<div className="space-y-0.5 px-2">
{/* Posta in Arrivo globale */}
<NavLink
to="/inbox"
end
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Posta in Arrivo (tutte le caselle)' : undefined}
>
<Inbox className="h-4 w-4 flex-shrink-0" />
{!collapsed && (
<>
<span className="flex-1">Posta in Arrivo</span>
{unreadCount > 0 && (
<span className="inline-flex items-center justify-center h-5 min-w-[20px] px-1 rounded-full bg-blue-500 text-white text-xs font-bold">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</>
)}
{/* Badge compatto in modalità collassata */}
{collapsed && unreadCount > 0 && (
<span className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</NavLink>
{/* Posta Inviata globale */}
<NavLink
to="/sent"
end
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Posta Inviata (tutte le caselle)' : undefined}
>
<Send className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Posta Inviata</span>}
</NavLink>
{/* Preferiti globali */}
<NavLink
to="/starred"
end
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Preferiti (tutte le caselle)' : undefined}
>
<Star className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Preferiti</span>}
</NavLink>
{/* Archiviati globali */}
<NavLink
to="/archived"
end
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Archiviati (tutte le caselle)' : undefined}
>
<Archive className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Archiviati</span>}
</NavLink>
</div>
</div>
{/* ── Sezione: Caselle individuali ── */}
{mailboxes.length > 0 && (
<div>
{!collapsed && (
<>
<div className="border-t border-gray-700 mx-4 mb-3" />
<p className="px-4 mb-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Le tue caselle
</p>
</>
)}
{collapsed && <div className="border-t border-gray-700 mx-3 mb-2" />}
<div className="space-y-0.5 px-2">
{mailboxes.map((mailbox) => (
<MailboxNavItem
key={mailbox.id}
mailbox={mailbox}
collapsed={collapsed}
isExpanded={isMailboxExpanded(mailbox.id)}
onToggle={() => toggleMailbox(mailbox.id)}
/>
))}
</div>
</div>
)}
{/* ── Sezione: Le tue Virtual Box ── */}
{myVboxes.length > 0 && (
<div>
{!collapsed && (
<>
<div className="border-t border-gray-700 mx-4 mb-3" />
<p className="px-4 mb-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Le tue virtual box
</p>
</>
)}
{collapsed && <div className="border-t border-gray-700 mx-3 mb-2" />}
<div className="space-y-0.5 px-2">
{myVboxes.map((vbox) => (
<VirtualBoxNavItem
key={vbox.id}
vbox={vbox}
collapsed={collapsed}
isExpanded={isVboxExpanded(vbox.id)}
onToggle={() => toggleVbox(vbox.id)}
/>
))}
</div>
</div>
)}
{/* ── Nuova PEC ── */}
<div>
<div className="border-t border-gray-700 mx-4 mb-3" />
<div className="px-2">
<NavLink
to="/compose"
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Nuova PEC' : undefined}
>
<MailCheck className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>Nuova PEC</span>}
</NavLink>
</div>
</div>
{/* ── Sezione Amministrazione ── */}
{isAdmin && (
<div>
{!collapsed && (
<>
<div className="border-t border-gray-700 mx-4 mb-3" />
<p className="px-4 mb-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Amministrazione
</p>
</>
)}
{collapsed && <div className="border-t border-gray-700 mx-3 mb-2" />}
<div className="space-y-0.5 px-2">
{([
{ to: '/mailboxes', label: 'Caselle PEC', icon: MailCheck },
{ to: '/users', label: 'Utenti', icon: Users },
{ to: '/permissions', label: 'Permessi', icon: Shield },
{ to: '/virtual-boxes', label: 'Virtual Box', icon: Filter },
{ to: '/notifications', label: 'Notifiche', icon: Bell },
] as const).map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? item.label : undefined}
>
<item.icon className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>{item.label}</span>}
</NavLink>
))}
</div>
</div>
)}
{/* ── Sezione Super Admin visibile solo ai super_admin ── */}
{isSuperAdmin && (
<div>
{!collapsed && (
<>
<div className="border-t border-purple-900/50 mx-4 mb-3" />
<p className="px-4 mb-1.5 text-xs font-semibold text-purple-400 uppercase tracking-wider">
Super Admin
</p>
</>
)}
{collapsed && <div className="border-t border-purple-900/50 mx-3 mb-2" />}
<div className="space-y-0.5 px-2">
<NavLink
to="/multitenant"
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-purple-700 text-white'
: 'text-purple-300 hover:bg-purple-900/40 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Multi-Tenant' : undefined}
>
<Building2 className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>Multi-Tenant</span>}
</NavLink>
</div>
</div>
)}
</nav>
{/* ── Profilo utente + logout ── */}
<div className="border-t border-gray-700 p-3">
{!collapsed ? (
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium flex-shrink-0">
{user?.full_name?.[0]?.toUpperCase() ?? 'U'}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{user?.full_name}</p>
<p className="text-xs text-gray-400 truncate">{user?.email}</p>
</div>
</div>
<div className="flex gap-2">
<NavLink
to="/settings"
className="flex-1 flex items-center gap-2 px-2 py-1.5 rounded text-xs text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
>
<Settings className="h-3.5 w-3.5" />
Impostazioni
</NavLink>
<button
onClick={handleLogout}
className="flex-1 flex items-center gap-2 px-2 py-1.5 rounded text-xs text-gray-400 hover:text-red-400 hover:bg-gray-700 transition-colors"
>
<LogOut className="h-3.5 w-3.5" />
Esci
</button>
</div>
</div>
) : (
<button
onClick={handleLogout}
className="w-full flex justify-center p-2 rounded text-gray-400 hover:text-red-400 hover:bg-gray-700 transition-colors"
title="Esci"
>
<LogOut className="h-4 w-4" />
</button>
)}
</div>
</aside>
)
}
// ─── Voce di casella PEC nel sidebar ─────────────────────────────────────────
interface MailboxNavItemProps {
mailbox: MailboxResponse
collapsed: boolean
isExpanded: boolean
onToggle: () => void
}
/** Colore del pallino di stato casella */
function statusDot(status: MailboxResponse['status']): string {
switch (status) {
case 'active':
return 'bg-green-500'
case 'paused':
return 'bg-yellow-400'
case 'error':
return 'bg-red-500'
case 'deleted':
return 'bg-gray-600'
default:
return 'bg-gray-500'
}
}
function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle }: MailboxNavItemProps) {
const displayName = mailbox.display_name || mailbox.email_address
const initial = displayName[0]?.toUpperCase() ?? '?'
const dotClass = statusDot(mailbox.status)
/* ── Modalità compressa: solo avatar/iniziale → link diretto all'inbox ── */
if (collapsed) {
return (
<NavLink
to={`/mailbox/${mailbox.id}/inbox`}
className={({ isActive }) =>
cn(
'relative flex justify-center items-center w-full px-2 py-2 rounded-lg transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
)
}
title={displayName}
>
<div className="relative">
<div className="h-6 w-6 rounded-full bg-gray-600 flex items-center justify-center text-xs font-semibold">
{initial}
</div>
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-gray-900',
dotClass,
)}
/>
</div>
</NavLink>
)
}
/* ── Modalità espansa: sezione espandibile con In Arrivo, Inviata, Preferiti, Archiviati ── */
return (
<div>
{/* Intestazione casella (espandi/comprimi) */}
<button
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition-colors group"
>
{/* Avatar + status dot */}
<div className="relative flex-shrink-0">
<div className="h-5 w-5 rounded-full bg-gray-600 flex items-center justify-center text-xs font-semibold text-white">
{initial}
</div>
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-gray-900',
dotClass,
)}
/>
</div>
{/* Nome / email */}
<span className="flex-1 text-left truncate text-xs leading-tight">
{displayName}
</span>
{/* Chevron espandi/comprimi */}
<ChevronDown
className={cn(
'h-3.5 w-3.5 text-gray-500 transition-transform flex-shrink-0',
isExpanded && 'rotate-180',
)}
/>
</button>
{/* Sub-voci: In Arrivo, Inviata, Preferiti, Archiviati */}
{isExpanded && (
<div className="ml-4 mt-0.5 mb-1 space-y-0.5 border-l border-gray-700 pl-3">
<NavLink
to={`/mailbox/${mailbox.id}/inbox`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Inbox className="h-3.5 w-3.5 flex-shrink-0" />
<span>In Arrivo</span>
</NavLink>
<NavLink
to={`/mailbox/${mailbox.id}/sent`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Send className="h-3.5 w-3.5 flex-shrink-0" />
<span>Inviata</span>
</NavLink>
<NavLink
to={`/mailbox/${mailbox.id}/starred`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Star className="h-3.5 w-3.5 flex-shrink-0" />
<span>Preferiti</span>
</NavLink>
<NavLink
to={`/mailbox/${mailbox.id}/archived`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Archive className="h-3.5 w-3.5 flex-shrink-0" />
<span>Archiviati</span>
</NavLink>
</div>
)}
</div>
)
}
// ─── Voce di Virtual Box nel sidebar ─────────────────────────────────────────
interface VirtualBoxNavItemProps {
vbox: VirtualBoxResponse
collapsed: boolean
isExpanded: boolean
onToggle: () => void
}
function VirtualBoxNavItem({ vbox, collapsed, isExpanded, onToggle }: VirtualBoxNavItemProps) {
const displayName = vbox.label || vbox.name
const initial = displayName[0]?.toUpperCase() ?? '?'
/* ── Modalità compressa: solo icona filtro → link diretto all'inbox ── */
if (collapsed) {
return (
<NavLink
to={`/virtual-box/${vbox.id}/inbox`}
className={({ isActive }) =>
cn(
'relative flex justify-center items-center w-full px-2 py-2 rounded-lg transition-colors',
isActive
? 'bg-purple-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
)
}
title={displayName}
>
<div className="h-6 w-6 rounded-full bg-purple-800 flex items-center justify-center text-xs font-semibold">
{initial}
</div>
</NavLink>
)
}
/* ── Modalità espansa: sezione espandibile ── */
return (
<div>
{/* Intestazione VBox (espandi/comprimi) */}
<button
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
>
{/* Icona VBox */}
<div className="h-5 w-5 rounded-full bg-purple-800 flex items-center justify-center text-xs font-semibold text-white flex-shrink-0">
{initial}
</div>
{/* Nome */}
<span className="flex-1 text-left truncate text-xs leading-tight">
{displayName}
</span>
{/* Chevron espandi/comprimi */}
<ChevronDown
className={cn(
'h-3.5 w-3.5 text-gray-500 transition-transform flex-shrink-0',
isExpanded && 'rotate-180',
)}
/>
</button>
{/* Sub-voci: In Arrivo, Inviata, Preferiti, Archiviati */}
{isExpanded && (
<div className="ml-4 mt-0.5 mb-1 space-y-0.5 border-l border-purple-800/40 pl-3">
<NavLink
to={`/virtual-box/${vbox.id}/inbox`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Inbox className="h-3.5 w-3.5 flex-shrink-0" />
<span>In Arrivo</span>
</NavLink>
<NavLink
to={`/virtual-box/${vbox.id}/sent`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Send className="h-3.5 w-3.5 flex-shrink-0" />
<span>Inviata</span>
</NavLink>
<NavLink
to={`/virtual-box/${vbox.id}/starred`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Star className="h-3.5 w-3.5 flex-shrink-0" />
<span>Preferiti</span>
</NavLink>
<NavLink
to={`/virtual-box/${vbox.id}/archived`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-purple-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white',
)
}
>
<Archive className="h-3.5 w-3.5 flex-shrink-0" />
<span>Archiviati</span>
</NavLink>
</div>
)}
</div>
)
}