vbox funzionanti

This commit is contained in:
2026-03-19 11:41:10 +01:00
parent 538d6a6bec
commit b7f7c1f7c0
32 changed files with 6043 additions and 262 deletions
+45 -18
View File
@@ -7,44 +7,71 @@ import { ComposePage } from '@/pages/Compose/ComposePage'
import { MailboxesPage } from '@/pages/Mailboxes/MailboxesPage'
import { UsersPage } from '@/pages/Users/UsersPage'
import { PermissionsPage } from '@/pages/Permissions/PermissionsPage'
import { SettingsPage } from '@/pages/Settings/SettingsPage'
import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage'
import { NotificationsPage } from '@/pages/Notifications/NotificationsPage'
/**
* Routing principale dell'applicazione PecFlow.
*
* Struttura:
* - /login → LoginPage (pubblica)
* - /login → LoginPage (pubblica)
* - /* → AppLayout (richiede autenticazione)
* - /inbox → InboxPage
* - /sent → InboxPage (filtrata su outbound)
* - /messages/:id → MessageDetailPage
* - /compose → ComposePage
* - /mailboxes → MailboxesPage (admin)
* - /users → UsersPage (admin)
* - /permissions → PermissionsPage (admin)
* - / → redirect a /inbox
* - /inbox → Posta in arrivo (tutte le caselle)
* - /sent → Posta inviata (tutte le caselle)
* - /starred → Preferiti (tutte le caselle)
* - /archived → Archiviati (tutte le caselle)
* - /mailbox/:mailboxId/inbox → Posta in arrivo di una specifica casella
* - /mailbox/:mailboxId/sent → Posta inviata di una specifica casella
* - /mailbox/:mailboxId/starred → Preferiti di una specifica casella
* - /mailbox/:mailboxId/archived → Archiviati di una specifica casella
* - /messages/:id → Dettaglio messaggio
* - /compose → Nuova PEC
* - /mailboxes → Gestione caselle (admin)
* - /users → Gestione utenti (admin)
* - /permissions → Gestione permessi (admin)
*/
export default function App() {
return (
<BrowserRouter>
<Routes>
{/* Pagine pubbliche */}
{/* Pagina pubblica */}
<Route path="/login" element={<LoginPage />} />
{/* Pagine protette (dentro AppLayout) */}
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/inbox" replace />} />
<Route path="/inbox" element={<InboxPage />} />
<Route
path="/sent"
element={<InboxPage />}
/>
{/* Vista globale: tutte le caselle insieme */}
<Route path="/inbox" element={<InboxPage viewMode="inbox" />} />
<Route path="/sent" element={<InboxPage viewMode="sent" />} />
<Route path="/starred" element={<InboxPage viewMode="starred" />} />
<Route path="/archived" element={<InboxPage viewMode="archived" />} />
{/* Vista per singola casella PEC */}
<Route path="/mailbox/:mailboxId/inbox" element={<InboxPage viewMode="inbox" />} />
<Route path="/mailbox/:mailboxId/sent" element={<InboxPage viewMode="sent" />} />
<Route path="/mailbox/:mailboxId/starred" element={<InboxPage viewMode="starred" />} />
<Route path="/mailbox/:mailboxId/archived" element={<InboxPage viewMode="archived" />} />
{/* Vista per Virtual Box assegnata */}
<Route path="/virtual-box/:vboxId/inbox" element={<InboxPage viewMode="inbox" />} />
<Route path="/virtual-box/:vboxId/sent" element={<InboxPage viewMode="sent" />} />
<Route path="/virtual-box/:vboxId/starred" element={<InboxPage viewMode="starred" />} />
<Route path="/virtual-box/:vboxId/archived" element={<InboxPage viewMode="archived" />} />
<Route path="/messages/:id" element={<MessageDetailPage />} />
<Route path="/compose" element={<ComposePage />} />
{/* Pagine admin */}
<Route path="/mailboxes" element={<MailboxesPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/permissions" element={<PermissionsPage />} />
<Route path="/mailboxes" element={<MailboxesPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/permissions" element={<PermissionsPage />} />
<Route path="/virtual-boxes" element={<VirtualBoxesPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
{/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} />
{/* Fallback */}
<Route path="*" element={<Navigate to="/inbox" replace />} />
+24
View File
@@ -8,6 +8,8 @@ import type {
export interface MessageFilters {
page?: number
page_size?: number
/** Filtra per Virtual Box assegnata all'utente corrente */
vbox_id?: string
mailbox_id?: string
direction?: 'inbound' | 'outbound'
state?: string
@@ -17,6 +19,17 @@ export interface MessageFilters {
search?: string
}
export interface MessageBulkUpdatePayload {
ids: string[]
is_starred?: boolean
is_archived?: boolean
}
export interface MessageBulkUpdateResponse {
updated: number
items: MessageResponse[]
}
export const messagesApi = {
list: (filters: MessageFilters = {}) =>
apiClient
@@ -42,6 +55,17 @@ export const messagesApi = {
.patch<MessageResponse>(`/messages/${id}`, { is_archived: true })
.then((r) => r.data),
unarchive: (id: string) =>
apiClient
.patch<MessageResponse>(`/messages/${id}`, { is_archived: false })
.then((r) => r.data),
/** Aggiorna in blocco is_starred e/o is_archived su più messaggi */
bulkUpdate: (payload: MessageBulkUpdatePayload) =>
apiClient
.patch<MessageBulkUpdateResponse>('/messages/bulk', payload)
.then((r) => r.data),
getAttachments: (id: string) =>
apiClient.get<AttachmentResponse[]>(`/messages/${id}/attachments`).then((r) => r.data),
+81
View File
@@ -0,0 +1,81 @@
import apiClient from './client'
import type {
ChannelTestResult,
NotificationChannelCreate,
NotificationChannelListResponse,
NotificationChannelResponse,
NotificationChannelUpdate,
NotificationLogListResponse,
NotificationRuleCreate,
NotificationRuleListResponse,
NotificationRuleResponse,
NotificationRuleUpdate,
} from '@/types/api.types'
export const notificationsApi = {
// ── Channels ───────────────────────────────────────────────────────────────
/** Crea un canale di notifica. */
createChannel: (data: NotificationChannelCreate) =>
apiClient
.post<NotificationChannelResponse>('/notifications/channels', data)
.then((r) => r.data),
/** Lista canali. */
listChannels: (params?: { page?: number; page_size?: number }) =>
apiClient
.get<NotificationChannelListResponse>('/notifications/channels', { params })
.then((r) => r.data),
/** Dettaglio canale. */
getChannel: (id: string) =>
apiClient
.get<NotificationChannelResponse>(`/notifications/channels/${id}`)
.then((r) => r.data),
/** Aggiorna canale. */
updateChannel: (id: string, data: NotificationChannelUpdate) =>
apiClient
.patch<NotificationChannelResponse>(`/notifications/channels/${id}`, data)
.then((r) => r.data),
/** Elimina canale. */
deleteChannel: (id: string) => apiClient.delete(`/notifications/channels/${id}`),
/** Test canale. */
testChannel: (id: string) =>
apiClient
.post<ChannelTestResult>(`/notifications/channels/${id}/test`)
.then((r) => r.data),
// ── Rules ──────────────────────────────────────────────────────────────────
/** Crea una regola. */
createRule: (data: NotificationRuleCreate) =>
apiClient
.post<NotificationRuleResponse>('/notifications/rules', data)
.then((r) => r.data),
/** Lista regole. */
listRules: (params?: { channel_id?: string; page?: number; page_size?: number }) =>
apiClient
.get<NotificationRuleListResponse>('/notifications/rules', { params })
.then((r) => r.data),
/** Aggiorna regola. */
updateRule: (id: string, data: NotificationRuleUpdate) =>
apiClient
.patch<NotificationRuleResponse>(`/notifications/rules/${id}`, data)
.then((r) => r.data),
/** Elimina regola. */
deleteRule: (id: string) => apiClient.delete(`/notifications/rules/${id}`),
// ── Logs ───────────────────────────────────────────────────────────────────
/** Lista log notifiche. */
listLogs: (params?: { channel_id?: string; page?: number; page_size?: number }) =>
apiClient
.get<NotificationLogListResponse>('/notifications/logs', { params })
.then((r) => r.data),
}
+78
View File
@@ -0,0 +1,78 @@
import apiClient from './client'
import type {
AssignedUserResponse,
MailboxBriefResponse,
VirtualBoxAssignmentResponse,
VirtualBoxAssignRequest,
VirtualBoxCreate,
VirtualBoxListResponse,
VirtualBoxMailboxAssignRequest,
VirtualBoxResponse,
VirtualBoxRuleCreate,
VirtualBoxUpdate,
} from '@/types/api.types'
export const virtualBoxesApi = {
/** Crea una nuova Virtual Box. */
create: (data: VirtualBoxCreate) =>
apiClient.post<VirtualBoxResponse>('/virtual-boxes', data).then((r) => r.data),
/** Lista Virtual Box del tenant. */
list: (params?: { page?: number; page_size?: number; active_only?: boolean }) =>
apiClient
.get<VirtualBoxListResponse>('/virtual-boxes', { params })
.then((r) => r.data),
/** Virtual Box assegnate all'utente corrente. */
myVirtualBoxes: () =>
apiClient.get<VirtualBoxResponse[]>('/virtual-boxes/my').then((r) => r.data),
/** Dettaglio Virtual Box. */
get: (id: string) =>
apiClient.get<VirtualBoxResponse>(`/virtual-boxes/${id}`).then((r) => r.data),
/** Aggiorna Virtual Box (incluse caselle se fornito mailbox_ids). */
update: (id: string, data: VirtualBoxUpdate) =>
apiClient.patch<VirtualBoxResponse>(`/virtual-boxes/${id}`, data).then((r) => r.data),
/** Elimina Virtual Box. */
delete: (id: string) => apiClient.delete(`/virtual-boxes/${id}`),
/** Sostituisce le regole di una Virtual Box. */
replaceRules: (id: string, rules: VirtualBoxRuleCreate[]) =>
apiClient
.put<VirtualBoxResponse>(`/virtual-boxes/${id}/rules`, rules)
.then((r) => r.data),
// ─── Caselle reali ─────────────────────────────────────────────────────────
/** Imposta le caselle PEC reali associate (sostituzione completa). */
setMailboxes: (id: string, data: VirtualBoxMailboxAssignRequest) =>
apiClient
.put<VirtualBoxResponse>(`/virtual-boxes/${id}/mailboxes`, data)
.then((r) => r.data),
/** Lista caselle PEC reali associate. */
listMailboxes: (id: string) =>
apiClient
.get<MailboxBriefResponse[]>(`/virtual-boxes/${id}/mailboxes`)
.then((r) => r.data),
// ─── Assegnazioni utenti ───────────────────────────────────────────────────
/** Assegna utenti a una Virtual Box. */
assignUsers: (id: string, data: VirtualBoxAssignRequest) =>
apiClient
.post<VirtualBoxAssignmentResponse[]>(`/virtual-boxes/${id}/assignments`, data)
.then((r) => r.data),
/** Rimuovi assegnazione utente. */
unassignUser: (vboxId: string, userId: string) =>
apiClient.delete(`/virtual-boxes/${vboxId}/assignments/${userId}`),
/** Lista utenti assegnati. */
listAssignedUsers: (id: string) =>
apiClient
.get<AssignedUserResponse[]>(`/virtual-boxes/${id}/assignments`)
.then((r) => r.data),
}
+608 -78
View File
@@ -1,3 +1,37 @@
/**
* Sidebar navigazione principale di PecFlow.
*
* Struttura visiva (sidebar espansa):
* ┌────────────────────────────────┐
* │ [PF] PecFlow [◀] │
* ├────────────────────────────────┤
* │ 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,
@@ -9,38 +43,80 @@ import {
ChevronLeft,
ChevronRight,
Shield,
ChevronDown,
Filter,
Bell,
Star,
Archive,
} 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'
interface NavItem {
to: string
label: string
icon: React.ElementType
adminOnly?: boolean
badge?: number
}
const NAV_ITEMS: NavItem[] = [
{ to: '/inbox', label: 'Posta in Arrivo', icon: Inbox },
{ to: '/sent', label: 'Posta Inviata', icon: Send },
{ to: '/compose', label: 'Nuova PEC', icon: MailCheck },
]
const ADMIN_NAV_ITEMS: NavItem[] = [
{ to: '/mailboxes', label: 'Caselle PEC', icon: MailCheck, adminOnly: true },
{ to: '/users', label: 'Utenti', icon: Users, adminOnly: true },
{ to: '/permissions', label: 'Permessi', icon: Shield, adminOnly: true },
]
// ─── 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, 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()
@@ -53,15 +129,15 @@ export function Sidebar() {
return (
<aside
className={cn(
'flex flex-col h-screen bg-gray-900 text-white transition-all duration-300',
'flex flex-col h-screen bg-gray-900 text-white transition-all duration-300 flex-shrink-0',
collapsed ? 'w-16' : 'w-64',
)}
>
{/* Logo + toggle */}
{/* ── 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">
<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">PecFlow</span>
@@ -73,54 +149,253 @@ export function Sidebar() {
</div>
)}
<button
onClick={() => setCollapsed(!collapsed)}
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" />}
{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">
<div className="space-y-1 px-2">
{NAV_ITEMS.map((item) => (
<SidebarLink
key={item.to}
item={item}
collapsed={collapsed}
badge={item.to === '/inbox' ? unreadCount : undefined}
/>
))}
{/* ── 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 Admin */}
{isAdmin && (
<>
<div className={cn('mt-6 px-4 mb-2', collapsed && 'hidden')}>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Amministrazione
</p>
</div>
{!collapsed && <div className="border-t border-gray-700 mx-4 mb-2" />}
<div className="space-y-1 px-2">
{ADMIN_NAV_ITEMS.map((item) => (
<SidebarLink key={item.to} item={item} collapsed={collapsed} />
{/* ── 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>
)}
</nav>
{/* Profilo utente + logout */}
{/* ── 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'}
{user?.full_name?.[0]?.toUpperCase() ?? 'U'}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{user?.full_name}</p>
@@ -158,40 +433,295 @@ export function Sidebar() {
)
}
interface SidebarLinkProps {
item: NavItem
// ─── Voce di casella PEC nel sidebar ─────────────────────────────────────────
interface MailboxNavItemProps {
mailbox: MailboxResponse
collapsed: boolean
badge?: number
isExpanded: boolean
onToggle: () => void
}
function SidebarLink({ item, collapsed, badge }: SidebarLinkProps) {
const Icon = item.icon
/** 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 (
<NavLink
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}
>
<Icon className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<>
<span className="flex-1">{item.label}</span>
{badge !== undefined && badge > 0 && (
<span className="inline-flex items-center justify-center h-5 w-5 rounded-full bg-blue-500 text-white text-xs font-bold">
{badge > 99 ? '99+' : badge}
</span>
<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>
)}
</NavLink>
</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>
)
}
+529 -121
View File
@@ -1,5 +1,20 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
/**
* InboxPage visualizza la posta in arrivo, inviata, preferiti o archiviata.
*
* Può operare in quattro modalità (viewMode):
* - 'inbox' → Posta in Arrivo (solo inbound, is_archived=false)
* - 'sent' → Posta Inviata (solo outbound, is_archived=false)
* - 'starred' → Preferiti (tutte le direzioni, is_starred=true)
* - 'archived' → Archiviati (tutte le direzioni, is_archived=true)
*
* Funzionalità:
* - Selezione singola e multipla tramite checkbox
* - Barra azioni bulk (stella, rimuovi stella, archivia, rimuovi archivio)
* - Pulsanti azione rapida su hover di ogni riga (stella, archivia)
* - Tutte le azioni funzionano anche in senso inverso (unstar / unarchive)
*/
import { useEffect, useState, useCallback } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Inbox,
RefreshCw,
@@ -7,10 +22,15 @@ import {
Star,
Mail,
MailOpen,
Filter,
Send,
ChevronLeft,
ChevronRight,
Archive,
ArchiveX,
StarOff,
CheckSquare,
Square,
X,
} from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
@@ -19,66 +39,248 @@ import { Input } from '@/components/ui/Input'
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
import { messagesApi } from '@/api/messages.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { useInboxStore } from '@/store/inbox.store'
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
import { formatRelative, truncate } from '@/lib/utils'
import type { MessageResponse } from '@/types/api.types'
import { cn } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
export function InboxPage() {
// ─── Props ────────────────────────────────────────────────────────────────────
export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived'
interface InboxPageProps {
/** Modalità vista */
viewMode: InboxViewMode
}
// ─── Componente principale ─────────────────────────────────────────────────────
export function InboxPage({ viewMode }: InboxPageProps) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { filters, setFilters } = useInboxStore()
const [searchInput, setSearchInput] = useState('')
const [selectedDirection, setSelectedDirection] = useState<'all' | 'inbound' | 'outbound'>('all')
// Carica caselle per il filtro
// mailboxId è presente solo nei percorsi /mailbox/:mailboxId/...
// vboxId è presente solo nei percorsi /virtual-box/:vboxId/...
const { mailboxId, vboxId } = useParams<{ mailboxId?: string; vboxId?: string }>()
// ── Stato filtri locale ──────────────────────────────────────────────────────
const [searchInput, setSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [isReadFilter, setIsReadFilter] = useState<boolean | undefined>(undefined)
const [isStarredFilter, setIsStarredFilter] = useState<boolean | undefined>(undefined)
const [page, setPage] = useState(1)
const PAGE_SIZE = 50
// ── Stato selezione ─────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Resetta lo stato UI ogni volta che si cambia casella, virtual box o cartella
useEffect(() => {
setSearchInput('')
setDebouncedSearch('')
setIsReadFilter(undefined)
setIsStarredFilter(undefined)
setPage(1)
setSelectedIds(new Set())
}, [mailboxId, vboxId, viewMode])
// Debounce della ricerca
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchInput)
setPage(1)
}, 400)
return () => clearTimeout(timer)
}, [searchInput])
// ── Caselle (per breadcrumb + badge) ────────────────────────────────────────
const { data: mailboxesData } = useQuery({
queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(),
staleTime: 5 * 60 * 1000,
})
// Carica messaggi
const currentMailbox = mailboxId
? mailboxesData?.items.find((m) => m.id === mailboxId)
: undefined
// ── Virtual Box corrente (per breadcrumb) ────────────────────────────────────
const { data: myVboxes = [] } = useQuery({
queryKey: ['virtual-boxes', 'my'],
queryFn: () => virtualBoxesApi.myVirtualBoxes(),
staleTime: 5 * 60 * 1000,
enabled: !!vboxId,
})
const currentVbox = vboxId
? myVboxes.find((v) => v.id === vboxId)
: undefined
// ── Query messaggi ───────────────────────────────────────────────────────────
const queryFilters = (() => {
const base = {
vbox_id: vboxId,
mailbox_id: mailboxId,
search: debouncedSearch || undefined,
page,
page_size: PAGE_SIZE,
}
switch (viewMode) {
case 'inbox':
return {
...base,
direction: 'inbound' as const,
is_read: isReadFilter,
is_starred: isStarredFilter,
is_archived: false,
}
case 'sent':
return {
...base,
direction: 'outbound' as const,
is_starred: isStarredFilter,
is_archived: false,
}
case 'starred':
return {
...base,
is_starred: true,
is_archived: false,
}
case 'archived':
return {
...base,
is_archived: true,
}
}
})()
const {
data: messagesData,
isLoading,
refetch,
isRefetching,
} = useQuery({
queryKey: ['messages', filters],
queryFn: () =>
messagesApi.list({
...filters,
direction: selectedDirection === 'all' ? undefined : selectedDirection,
search: searchInput || undefined,
}),
refetchInterval: 60000, // refresh ogni minuto
queryKey: ['messages', queryFilters],
queryFn: () => messagesApi.list(queryFilters),
refetchInterval: 60_000,
})
// Segna come letto
const messages = messagesData?.items || []
const total = messagesData?.total || 0
const totalPages = Math.ceil(total / PAGE_SIZE)
// ── Invalida query messaggi dopo operazioni ──────────────────────────────────
const invalidateMessages = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['messages'] })
}, [queryClient])
// ── Segna come letto ─────────────────────────────────────────────────────────
const markReadMutation = useMutation({
mutationFn: messagesApi.markRead,
onSuccess: (updatedMsg) => {
queryClient.setQueryData(['messages', filters], (old: { items: MessageResponse[] } | undefined) => {
if (!old) return old
return {
...old,
items: old.items.map((m) => (m.id === updatedMsg.id ? updatedMsg : m)),
}
})
queryClient.setQueryData(
['messages', queryFilters],
(old: { items: MessageResponse[]; total: number } | undefined) => {
if (!old) return old
return {
...old,
items: old.items.map((m) => (m.id === updatedMsg.id ? updatedMsg : m)),
}
},
)
},
})
// Gestione ricerca con debounce
useEffect(() => {
const timer = setTimeout(() => {
setFilters({ search: searchInput || undefined, page: 1 })
}, 400)
return () => clearTimeout(timer)
}, [searchInput, setFilters])
// ── Toggle stella singolo ────────────────────────────────────────────────────
const toggleStarMutation = useMutation({
mutationFn: ({ id, starred }: { id: string; starred: boolean }) =>
messagesApi.toggleStar(id, starred),
onSuccess: (updatedMsg) => {
queryClient.setQueryData(
['messages', queryFilters],
(old: { items: MessageResponse[]; total: number } | undefined) => {
if (!old) return old
// In vista "starred" rimuoviamo il messaggio se è stato rimosso dai preferiti
if (viewMode === 'starred' && !updatedMsg.is_starred) {
return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 }
}
return { ...old, items: old.items.map((m) => (m.id === updatedMsg.id ? updatedMsg : m)) }
},
)
invalidateMessages()
},
onError: (error) => toast.error(getErrorMessage(error)),
})
// ── Archivia/Dearchivia singolo ──────────────────────────────────────────────
const archiveMutation = useMutation({
mutationFn: ({ id, archived }: { id: string; archived: boolean }) =>
archived ? messagesApi.archive(id) : messagesApi.unarchive(id),
onSuccess: (updatedMsg, { archived }) => {
// Rimuove il messaggio dalla lista corrente (ha cambiato "stanza")
queryClient.setQueryData(
['messages', queryFilters],
(old: { items: MessageResponse[]; total: number } | undefined) => {
if (!old) return old
return { ...old, items: old.items.filter((m) => m.id !== updatedMsg.id), total: old.total - 1 }
},
)
toast.success(archived ? 'Messaggio archiviato' : 'Messaggio ripristinato dalla posta')
invalidateMessages()
},
onError: (error) => toast.error(getErrorMessage(error)),
})
// ── Azioni bulk ──────────────────────────────────────────────────────────────
const bulkMutation = useMutation({
mutationFn: messagesApi.bulkUpdate,
onSuccess: (result, payload) => {
invalidateMessages()
setSelectedIds(new Set())
const n = result.updated
if (payload.is_starred === true) toast.success(`${n} ${n === 1 ? 'messaggio aggiunto' : 'messaggi aggiunti'} ai preferiti`)
else if (payload.is_starred === false) toast.success(`${n} ${n === 1 ? 'messaggio rimosso' : 'messaggi rimossi'} dai preferiti`)
else if (payload.is_archived === true) toast.success(`${n} ${n === 1 ? 'messaggio archiviato' : 'messaggi archiviati'}`)
else if (payload.is_archived === false) toast.success(`${n} ${n === 1 ? 'messaggio ripristinato' : 'messaggi ripristinati'}`)
},
onError: (error) => toast.error(getErrorMessage(error)),
})
const handleBulkStar = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_starred: true })
const handleBulkUnstar = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_starred: false })
const handleBulkArchive = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: true })
const handleBulkUnarchive = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_archived: false })
// ── Selezione ────────────────────────────────────────────────────────────────
const handleToggleSelect = (id: string, e: React.MouseEvent) => {
e.stopPropagation()
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleSelectAll = () => {
if (selectedIds.size === messages.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(messages.map((m) => m.id)))
}
}
const handleClearSelection = () => setSelectedIds(new Set())
// ── Click su messaggio ───────────────────────────────────────────────────────
const handleMessageClick = async (message: MessageResponse) => {
if (!message.is_read) {
if (!message.is_read && message.direction === 'inbound') {
markReadMutation.mutate(message.id)
}
navigate(`/messages/${message.id}`)
@@ -93,24 +295,65 @@ export function InboxPage() {
}
}
const messages = messagesData?.items || []
const total = messagesData?.total || 0
const currentPage = filters.page || 1
const pageSize = filters.page_size || 50
const totalPages = Math.ceil(total / pageSize)
// ── Label e icone folder ────────────────────────────────────────────────────
const isInbound = viewMode === 'inbox'
const folderLabel =
viewMode === 'inbox'
? 'Posta in Arrivo'
: viewMode === 'sent'
? 'Posta Inviata'
: viewMode === 'starred'
? 'Preferiti'
: 'Archiviati'
const FolderIcon =
viewMode === 'inbox'
? Inbox
: viewMode === 'sent'
? Send
: viewMode === 'starred'
? Star
: Archive
const selectedCount = selectedIds.size
const allSelected = messages.length > 0 && selectedCount === messages.length
const someSelected = selectedCount > 0
// ── Render ───────────────────────────────────────────────────────────────────
return (
<div className="flex flex-col h-full">
{/* Header */}
{/* ── Header ── */}
<div className="border-b bg-background px-6 py-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Inbox className="h-5 w-5 text-primary" />
<h1 className="text-xl font-semibold">Posta</h1>
{total > 0 && (
<span className="text-sm text-muted-foreground">({total} messaggi)</span>
)}
<div className="flex items-start gap-2">
<FolderIcon className="h-5 w-5 text-primary mt-1 flex-shrink-0" />
<div>
{currentMailbox ? (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium text-muted-foreground">
{currentMailbox.display_name || currentMailbox.email_address}
</span>
<span className="text-muted-foreground/40 text-sm"></span>
<h1 className="text-xl font-semibold">{folderLabel}</h1>
</div>
) : currentVbox ? (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium text-purple-600">
{currentVbox.label || currentVbox.name}
</span>
<span className="text-muted-foreground/40 text-sm"></span>
<h1 className="text-xl font-semibold">{folderLabel}</h1>
</div>
) : (
<h1 className="text-xl font-semibold">{folderLabel}</h1>
)}
{total > 0 && (
<p className="text-xs text-muted-foreground mt-0.5">
{total} {total === 1 ? 'messaggio' : 'messaggi'}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
@@ -128,111 +371,201 @@ export function InboxPage() {
</div>
</div>
{/* Filtri */}
{/* ── Filtri ── */}
<div className="flex items-center gap-3 flex-wrap">
{/* Barra di ricerca */}
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cerca per oggetto, mittente..."
placeholder={
isInbound ? 'Cerca per oggetto, mittente…' : 'Cerca per oggetto, destinatario…'
}
className="pl-9 h-9"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
{/* Filtro direzione */}
<div className="flex gap-1 p-1 rounded-lg bg-muted">
{(['all', 'inbound', 'outbound'] as const).map((d) => (
<button
key={d}
onClick={() => {
setSelectedDirection(d)
setFilters({ direction: d === 'all' ? undefined : d, page: 1 })
}}
className={cn(
'px-3 py-1 rounded-md text-xs font-medium transition-colors',
selectedDirection === d
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{d === 'all' ? 'Tutti' : d === 'inbound' ? '📥 In arrivo' : '📤 Inviati'}
</button>
))}
</div>
{/* Filtro casella */}
{mailboxesData?.items && mailboxesData.items.length > 1 && (
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
value={filters.mailbox_id || ''}
onChange={(e) => setFilters({ mailbox_id: e.target.value || undefined, page: 1 })}
>
<option value="">Tutte le caselle</option>
{mailboxesData.items.map((mb) => (
<option key={mb.id} value={mb.id}>
{mb.display_name || mb.email_address}
</option>
))}
</select>
)}
{/* Filtro letti/non letti */}
<div className="flex gap-1">
{viewMode === 'inbox' && (
<Button
variant={filters.is_read === false ? 'default' : 'outline'}
variant={isReadFilter === false ? 'default' : 'outline'}
size="sm"
onClick={() =>
setFilters({ is_read: filters.is_read === false ? undefined : false })
}
onClick={() => setIsReadFilter(isReadFilter === false ? undefined : false)}
className="h-9 text-xs"
>
<Mail className="h-3.5 w-3.5 mr-1" />
Non letti
</Button>
)}
{(viewMode === 'inbox' || viewMode === 'sent') && (
<Button
variant={filters.is_starred === true ? 'default' : 'outline'}
variant={isStarredFilter === true ? 'default' : 'outline'}
size="sm"
onClick={() =>
setFilters({ is_starred: filters.is_starred === true ? undefined : true })
}
onClick={() => setIsStarredFilter(isStarredFilter === true ? undefined : true)}
className="h-9 text-xs"
>
<Star className="h-3.5 w-3.5 mr-1" />
Preferiti
</Button>
</div>
)}
</div>
</div>
{/* Lista messaggi */}
{/* ── Barra azioni bulk (appare quando ci sono selezioni) ── */}
{someSelected && (
<div className="border-b bg-blue-50 dark:bg-blue-950/30 px-6 py-2.5 flex items-center gap-3 flex-wrap">
{/* Contatore + deseleziona */}
<div className="flex items-center gap-2">
<button
onClick={handleClearSelection}
className="p-0.5 rounded hover:bg-blue-100 dark:hover:bg-blue-900 transition-colors"
title="Deseleziona tutto"
>
<X className="h-4 w-4 text-blue-600" />
</button>
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{selectedCount} {selectedCount === 1 ? 'selezionato' : 'selezionati'}
</span>
</div>
<div className="h-4 w-px bg-blue-200 dark:bg-blue-700" />
{/* Azioni: variano in base alla vista */}
{viewMode !== 'starred' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkStar}
isLoading={bulkMutation.isPending}
>
<Star className="h-3.5 w-3.5 mr-1 text-yellow-500" />
Aggiungi ai preferiti
</Button>
)}
{viewMode === 'starred' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkUnstar}
isLoading={bulkMutation.isPending}
>
<StarOff className="h-3.5 w-3.5 mr-1" />
Rimuovi dai preferiti
</Button>
)}
{(viewMode === 'inbox' || viewMode === 'sent') && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkUnstar}
isLoading={bulkMutation.isPending}
>
<StarOff className="h-3.5 w-3.5 mr-1" />
Rimuovi dai preferiti
</Button>
)}
{viewMode !== 'archived' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkArchive}
isLoading={bulkMutation.isPending}
>
<Archive className="h-3.5 w-3.5 mr-1" />
Archivia
</Button>
)}
{viewMode === 'archived' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-blue-200 hover:bg-blue-100 dark:border-blue-700"
onClick={handleBulkUnarchive}
isLoading={bulkMutation.isPending}
>
<ArchiveX className="h-3.5 w-3.5 mr-1" />
Ripristina dalla posta
</Button>
)}
</div>
)}
{/* ── Lista messaggi ── */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Caricamento messaggi...</p>
<p className="text-sm text-muted-foreground">Caricamento messaggi</p>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Inbox className="h-12 w-12 text-muted-foreground/30 mb-3" />
<FolderIcon className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground font-medium">Nessun messaggio trovato</p>
<p className="text-sm text-muted-foreground/70 mt-1">
{searchInput ? 'Prova a modificare i filtri di ricerca' : 'La casella è vuota'}
{debouncedSearch || isReadFilter !== undefined || isStarredFilter !== undefined
? 'Prova a modificare i filtri di ricerca'
: viewMode === 'inbox'
? 'La posta in arrivo è vuota'
: viewMode === 'sent'
? 'Nessun messaggio inviato'
: viewMode === 'starred'
? 'Nessun messaggio nei preferiti'
: 'Nessun messaggio archiviato'}
</p>
</div>
) : (
<div className="divide-y">
{/* Riga "seleziona tutto" */}
{messages.length > 0 && (
<div className="flex items-center gap-3 px-6 py-2 bg-muted/20 border-b">
<button
onClick={handleSelectAll}
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{allSelected ? (
<CheckSquare className="h-4 w-4 text-primary" />
) : (
<Square className="h-4 w-4" />
)}
<span>
{allSelected
? 'Deseleziona tutti'
: `Seleziona tutti (${messages.length})`}
</span>
</button>
</div>
)}
{messages.map((message) => (
<MessageRow
key={message.id}
message={message}
viewMode={viewMode}
isSelected={selectedIds.has(message.id)}
onSelect={(e) => handleToggleSelect(message.id, e)}
onClick={() => handleMessageClick(message)}
onToggleStar={(e) => {
e.stopPropagation()
toggleStarMutation.mutate({ id: message.id, starred: !message.is_starred })
}}
onToggleArchive={(e) => {
e.stopPropagation()
archiveMutation.mutate({ id: message.id, archived: !message.is_archived })
}}
mailboxName={
mailboxesData?.items.find((m) => m.id === message.mailbox_id)
?.email_address
!mailboxId
? mailboxesData?.items.find((m) => m.id === message.mailbox_id)?.email_address
: undefined
}
/>
))}
@@ -240,26 +573,26 @@ export function InboxPage() {
)}
</div>
{/* Paginazione */}
{/* ── Paginazione ── */}
{totalPages > 1 && (
<div className="border-t px-6 py-3 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Pagina {currentPage} di {totalPages} ({total} messaggi)
Pagina {page} di {totalPages} ({total} messaggi)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => setFilters({ page: currentPage - 1 })}
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => setFilters({ page: currentPage + 1 })}
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
@@ -274,23 +607,59 @@ export function InboxPage() {
interface MessageRowProps {
message: MessageResponse
viewMode: InboxViewMode
isSelected: boolean
onSelect: (e: React.MouseEvent) => void
onClick: () => void
onToggleStar: (e: React.MouseEvent) => void
onToggleArchive: (e: React.MouseEvent) => void
/** Presente solo nella vista globale mostra la casella di appartenenza */
mailboxName?: string
}
function MessageRow({ message, onClick, mailboxName }: MessageRowProps) {
function MessageRow({
message,
viewMode,
isSelected,
onSelect,
onClick,
onToggleStar,
onToggleArchive,
mailboxName,
}: MessageRowProps) {
const [hovered, setHovered] = useState(false)
const isUnread = !message.is_read && message.direction === 'inbound'
return (
<div
className={cn(
'flex items-start gap-3 px-6 py-4 cursor-pointer hover:bg-muted/50 transition-colors',
isUnread && 'bg-blue-50/50',
'flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors group',
isUnread && 'bg-blue-50/50 dark:bg-blue-950/20',
isSelected && 'bg-blue-100/60 dark:bg-blue-900/30',
)}
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Icona direzione */}
<div className="mt-1 flex-shrink-0">
{/* ── Checkbox selezione ── */}
<div
className="flex-shrink-0 w-6 flex items-center justify-center"
onClick={onSelect}
>
{isSelected ? (
<CheckSquare className="h-4 w-4 text-primary" />
) : (
<Square
className={cn(
'h-4 w-4 transition-opacity',
hovered ? 'opacity-100 text-muted-foreground' : 'opacity-0',
)}
/>
)}
</div>
{/* ── Icona direzione ── */}
<div className="flex-shrink-0">
{message.direction === 'inbound' ? (
isUnread ? (
<Mail className="h-5 w-5 text-blue-600" />
@@ -302,7 +671,7 @@ function MessageRow({ message, onClick, mailboxName }: MessageRowProps) {
)}
</div>
{/* Contenuto */}
{/* ── Contenuto ── */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
@@ -313,15 +682,17 @@ function MessageRow({ message, onClick, mailboxName }: MessageRowProps) {
)}
>
{message.direction === 'inbound'
? message.from_address || 'Mittente sconosciuto'
? (message.from_address || 'Mittente sconosciuto')
: (message.to_addresses?.[0] || 'Destinatario sconosciuto')}
</span>
{mailboxName && (
<span className="text-xs text-muted-foreground flex-shrink-0">
{mailboxName}
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded flex-shrink-0">
{mailboxName}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<PecStateBadge state={message.state} />
<span className="text-xs text-muted-foreground">
@@ -351,9 +722,46 @@ function MessageRow({ message, onClick, mailboxName }: MessageRowProps) {
)}
</div>
{/* Indicatori */}
<div className="flex flex-col items-center gap-1 flex-shrink-0">
{message.is_starred && <Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />}
{/* ── Azioni rapide (visibili su hover) + indicatori fissi ── */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* Pulsante stella (rapido, su hover o se stellata) */}
<button
onClick={onToggleStar}
title={message.is_starred ? 'Rimuovi dai preferiti' : 'Aggiungi ai preferiti'}
className={cn(
'p-1 rounded hover:bg-muted transition-all',
message.is_starred
? 'opacity-100'
: hovered
? 'opacity-100'
: 'opacity-0 pointer-events-none',
)}
>
<Star
className={cn(
'h-4 w-4',
message.is_starred ? 'text-yellow-500 fill-yellow-500' : 'text-muted-foreground',
)}
/>
</button>
{/* Pulsante archivia/ripristina (rapido, su hover) */}
<button
onClick={onToggleArchive}
title={viewMode === 'archived' ? 'Ripristina dalla posta' : 'Archivia'}
className={cn(
'p-1 rounded hover:bg-muted transition-all',
hovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
>
{viewMode === 'archived' ? (
<ArchiveX className="h-4 w-4 text-muted-foreground" />
) : (
<Archive className="h-4 w-4 text-muted-foreground" />
)}
</button>
{/* Indicatore allegati */}
{message.has_attachments && (
<span className="text-xs text-muted-foreground">📎</span>
)}
@@ -4,9 +4,9 @@ import {
ArrowLeft,
Star,
Archive,
ArchiveX,
Download,
Reply,
Forward,
Paperclip,
Mail,
Send,
@@ -16,7 +16,7 @@ import { Button } from '@/components/ui/Button'
import { PecStateBadge, PecTypeBadge } from '@/components/PecBadge/PecBadge'
import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree'
import { messagesApi } from '@/api/messages.api'
import { formatDate, formatBytes, MAILBOX_STATUS_LABELS } from '@/lib/utils'
import { formatDate, formatBytes } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
export function MessageDetailPage() {
@@ -54,6 +54,8 @@ export function MessageDetailPage() {
mutationFn: (starred: boolean) => messagesApi.toggleStar(id!, starred),
onSuccess: (updated) => {
queryClient.setQueryData(['message', id], updated)
// Invalida le query messaggi per aggiornare le viste Preferiti
queryClient.invalidateQueries({ queryKey: ['messages'] })
toast.success(updated.is_starred ? 'Aggiunto ai preferiti' : 'Rimosso dai preferiti')
},
onError: (error) => toast.error(getErrorMessage(error)),
@@ -62,9 +64,21 @@ export function MessageDetailPage() {
// Archivia
const archiveMutation = useMutation({
mutationFn: () => messagesApi.archive(id!),
onSuccess: () => {
onSuccess: (updated) => {
queryClient.setQueryData(['message', id], updated)
queryClient.invalidateQueries({ queryKey: ['messages'] })
toast.success('Messaggio archiviato')
navigate('/inbox')
},
onError: (error) => toast.error(getErrorMessage(error)),
})
// Ripristina dall'archivio
const unarchiveMutation = useMutation({
mutationFn: () => messagesApi.unarchive(id!),
onSuccess: (updated) => {
queryClient.setQueryData(['message', id], updated)
queryClient.invalidateQueries({ queryKey: ['messages'] })
toast.success('Messaggio ripristinato dalla posta')
},
onError: (error) => toast.error(getErrorMessage(error)),
})
@@ -103,29 +117,46 @@ export function MessageDetailPage() {
</div>
<div className="flex items-center gap-2">
{/* Stella */}
{/* Stella / Preferito */}
<Button
variant="ghost"
size="icon"
onClick={() => toggleStarMutation.mutate(!message.is_starred)}
title={message.is_starred ? 'Rimuovi dai preferiti' : 'Aggiungi ai preferiti'}
isLoading={toggleStarMutation.isPending}
>
<Star
className={`h-5 w-5 ${message.is_starred ? 'text-yellow-500 fill-yellow-500' : 'text-muted-foreground'}`}
/>
</Button>
{/* Archivia */}
{/* Archivia (se non ancora archiviato) */}
{!message.is_archived && (
<Button
variant="ghost"
size="icon"
onClick={() => archiveMutation.mutate()}
title="Archivia"
isLoading={archiveMutation.isPending}
>
<Archive className="h-5 w-5 text-muted-foreground" />
</Button>
)}
{/* Ripristina dall'archivio (se archiviato) */}
{message.is_archived && (
<Button
variant="outline"
size="sm"
onClick={() => unarchiveMutation.mutate()}
title="Ripristina dalla posta"
isLoading={unarchiveMutation.isPending}
>
<ArchiveX className="h-4 w-4 mr-1" />
Ripristina
</Button>
)}
{/* Rispondi (solo per messaggi inbound PEC certificata) */}
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && (
<Button
@@ -144,6 +175,26 @@ export function MessageDetailPage() {
</div>
</div>
{/* Banner "Archiviato" */}
{message.is_archived && (
<div className="bg-amber-50 border-b border-amber-200 px-6 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-amber-700">
<Archive className="h-4 w-4" />
<span>Questo messaggio si trova nell'archivio.</span>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs border-amber-300 hover:bg-amber-100 text-amber-700"
onClick={() => unarchiveMutation.mutate()}
isLoading={unarchiveMutation.isPending}
>
<ArchiveX className="h-3.5 w-3.5 mr-1" />
Ripristina nella posta
</Button>
</div>
)}
{/* Contenuto */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-6 py-8 space-y-6">
@@ -0,0 +1,954 @@
/**
* Pagina Notifiche Multi-canale gestione canali e regole di notifica.
*
* Struttura:
* - Tab "Canali" → lista canali con tipo, stato, circuit breaker, pulsante test
* - Tab "Regole" → lista regole evento→canale con filtri opzionali
* - Tab "Log" → log degli invii recenti
*
* Canali supportati: Webhook, Email SMTP, Telegram, WhatsApp.
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Bell,
Plus,
Edit,
Trash2,
Webhook,
Mail,
MessageCircle,
Phone,
CheckCircle,
XCircle,
FlaskConical,
AlertTriangle,
Zap,
} from 'lucide-react'
import { useForm } from 'react-hook-form'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog'
import { notificationsApi } from '@/api/notifications.api'
import { getErrorMessage } from '@/api/client'
import { formatDate, formatRelative } from '@/lib/utils'
import { cn } from '@/lib/utils'
import type {
NotificationChannelCreate,
NotificationChannelResponse,
NotificationChannelType,
NotificationEventType,
NotificationRuleCreate,
NotificationRuleResponse,
} from '@/types/api.types'
// ─── Costanti ─────────────────────────────────────────────────────────────────
const CHANNEL_TYPE_LABELS: Record<NotificationChannelType, string> = {
webhook: 'Webhook',
email: 'Email SMTP',
telegram: 'Telegram',
whatsapp: 'WhatsApp',
}
const CHANNEL_TYPE_ICONS: Record<NotificationChannelType, React.ElementType> = {
webhook: Webhook,
email: Mail,
telegram: MessageCircle,
whatsapp: Phone,
}
const CHANNEL_TYPE_COLORS: Record<NotificationChannelType, string> = {
webhook: 'bg-purple-100 text-purple-800',
email: 'bg-blue-100 text-blue-800',
telegram: 'bg-sky-100 text-sky-800',
whatsapp: 'bg-green-100 text-green-800',
}
const EVENT_TYPE_LABELS: Record<NotificationEventType, string> = {
new_message: 'Nuovo messaggio',
state_changed: 'Cambio stato',
anomaly: 'Anomalia',
send_failed: 'Invio fallito',
send_delivered: 'Invio consegnato',
mailbox_error: 'Errore casella',
}
const STATUS_COLORS: Record<string, string> = {
sent: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
failed: 'bg-red-100 text-red-800',
skipped: 'bg-gray-100 text-gray-600',
}
const STATUS_LABELS: Record<string, string> = {
sent: 'Inviato',
pending: 'In attesa',
failed: 'Fallito',
skipped: 'Saltato',
}
// ─── Tab type ─────────────────────────────────────────────────────────────────
type Tab = 'channels' | 'rules' | 'logs'
// ─── Pagina principale ────────────────────────────────────────────────────────
export function NotificationsPage() {
const [activeTab, setActiveTab] = useState<Tab>('channels')
const [showCreateChannel, setShowCreateChannel] = useState(false)
const [editingChannel, setEditingChannel] = useState<NotificationChannelResponse | null>(null)
const [showCreateRule, setShowCreateRule] = useState(false)
const queryClient = useQueryClient()
const tabs: { id: Tab; label: string }[] = [
{ id: 'channels', label: 'Canali' },
{ id: 'rules', label: 'Regole' },
{ id: 'logs', label: 'Log invii' },
]
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
<h1 className="text-xl font-semibold">Notifiche Multi-canale</h1>
</div>
{activeTab === 'channels' && (
<Button onClick={() => setShowCreateChannel(true)}>
<Plus className="h-4 w-4 mr-2" />
Nuovo canale
</Button>
)}
{activeTab === 'rules' && (
<Button onClick={() => setShowCreateRule(true)}>
<Plus className="h-4 w-4 mr-2" />
Nuova regola
</Button>
)}
</div>
{/* Tab bar */}
<div className="border-b bg-background px-6">
<div className="flex gap-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Contenuto */}
<div className="flex-1 overflow-auto p-6">
{activeTab === 'channels' && (
<ChannelsTab
onEdit={(c) => setEditingChannel(c)}
onCreated={() => queryClient.invalidateQueries({ queryKey: ['notif-channels'] })}
/>
)}
{activeTab === 'rules' && <RulesTab />}
{activeTab === 'logs' && <LogsTab />}
</div>
{/* Dialogs */}
{(showCreateChannel || editingChannel) && (
<ChannelFormDialog
open
editingChannel={editingChannel}
onClose={() => {
setShowCreateChannel(false)
setEditingChannel(null)
}}
onSaved={() => {
queryClient.invalidateQueries({ queryKey: ['notif-channels'] })
setShowCreateChannel(false)
setEditingChannel(null)
}}
/>
)}
{showCreateRule && (
<RuleFormDialog
open
onClose={() => setShowCreateRule(false)}
onSaved={() => {
queryClient.invalidateQueries({ queryKey: ['notif-rules'] })
setShowCreateRule(false)
}}
/>
)}
</div>
)
}
// ─── Tab Canali ───────────────────────────────────────────────────────────────
interface ChannelsTabProps {
onEdit: (c: NotificationChannelResponse) => void
onCreated: () => void
}
function ChannelsTab({ onEdit }: ChannelsTabProps) {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['notif-channels'],
queryFn: () => notificationsApi.listChannels({ page_size: 100 }),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => notificationsApi.deleteChannel(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notif-channels'] })
toast.success('Canale eliminato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const testMutation = useMutation({
mutationFn: (id: string) => notificationsApi.testChannel(id),
onSuccess: (result) => {
if (result.success) {
toast.success(`${result.message}`)
} else {
toast.error(`${result.message}`)
}
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggleActiveMutation = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) =>
notificationsApi.updateChannel(id, { is_active: active }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notif-channels'] }),
onError: (e) => toast.error(getErrorMessage(e)),
})
const channels = data?.items ?? []
if (isLoading)
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
if (channels.length === 0)
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Bell className="h-12 w-12 text-muted-foreground mb-4 opacity-40" />
<p className="text-lg font-medium text-muted-foreground">Nessun canale configurato</p>
<p className="text-sm text-muted-foreground mt-1">
Aggiungi un canale Webhook, Email, Telegram o WhatsApp per ricevere notifiche.
</p>
</div>
)
return (
<div className="space-y-3">
{channels.map((channel) => {
const Icon = CHANNEL_TYPE_ICONS[channel.channel_type as NotificationChannelType] ?? Bell
const isCircuitOpen =
channel.circuit_open_until && new Date(channel.circuit_open_until) > new Date()
return (
<div
key={channel.id}
className={cn(
'rounded-lg border bg-card p-4 flex items-start gap-4',
!channel.is_active && 'opacity-60',
)}
>
{/* Icona tipo */}
<div
className={cn(
'p-2.5 rounded-lg flex-shrink-0',
CHANNEL_TYPE_COLORS[channel.channel_type as NotificationChannelType],
)}
>
<Icon className="h-5 w-5" />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{channel.name}</span>
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
CHANNEL_TYPE_COLORS[channel.channel_type as NotificationChannelType],
)}
>
{CHANNEL_TYPE_LABELS[channel.channel_type as NotificationChannelType] ??
channel.channel_type}
</span>
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
channel.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600',
)}
>
{channel.is_active ? 'Attivo' : 'Inattivo'}
</span>
</div>
{/* Config pubblica */}
{channel.config && Object.keys(channel.config).length > 0 && (
<div className="mt-1 flex gap-3 flex-wrap">
{Object.entries(channel.config).map(([k, v]) => (
<span key={k} className="text-xs text-muted-foreground">
<span className="font-medium">{k}:</span>{' '}
<span className="font-mono">{String(v)}</span>
</span>
))}
</div>
)}
{/* Circuit breaker warning */}
{isCircuitOpen && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-orange-700 bg-orange-50 rounded px-2 py-1 w-fit">
<AlertTriangle className="h-3.5 w-3.5" />
Circuit breaker aperto fino a {formatDate(channel.circuit_open_until)}
</div>
)}
{/* Failures */}
{channel.consecutive_failures > 0 && (
<p className="text-xs text-red-600 mt-1">
{channel.consecutive_failures} errori consecutivi
</p>
)}
</div>
{/* Azioni */}
<div className="flex items-center gap-1 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => testMutation.mutate(channel.id)}
isLoading={testMutation.isPending}
title="Testa canale"
>
<FlaskConical className="h-3.5 w-3.5 mr-1" />
Test
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Modifica"
onClick={() => onEdit(channel)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={channel.is_active ? 'Disattiva' : 'Attiva'}
onClick={() =>
toggleActiveMutation.mutate({ id: channel.id, active: !channel.is_active })
}
>
{channel.is_active ? (
<XCircle className="h-4 w-4 text-yellow-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Elimina"
onClick={() => {
if (confirm(`Eliminare il canale "${channel.name}"?`)) {
deleteMutation.mutate(channel.id)
}
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
)
})}
</div>
)
}
// ─── Tab Regole ───────────────────────────────────────────────────────────────
function RulesTab() {
const queryClient = useQueryClient()
const { data: channelsData } = useQuery({
queryKey: ['notif-channels'],
queryFn: () => notificationsApi.listChannels({ page_size: 100 }),
})
const { data, isLoading } = useQuery({
queryKey: ['notif-rules'],
queryFn: () => notificationsApi.listRules({ page_size: 100 }),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => notificationsApi.deleteRule(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notif-rules'] })
toast.success('Regola eliminata')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggleActiveMutation = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) =>
notificationsApi.updateRule(id, { is_active: active }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notif-rules'] }),
onError: (e) => toast.error(getErrorMessage(e)),
})
const channels = channelsData?.items ?? []
const rules = data?.items ?? []
const channelName = (id: string) =>
channels.find((c) => c.id === id)?.name ?? id.slice(0, 8) + '…'
if (isLoading)
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
if (rules.length === 0)
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Zap className="h-12 w-12 text-muted-foreground mb-4 opacity-40" />
<p className="text-lg font-medium text-muted-foreground">Nessuna regola</p>
<p className="text-sm text-muted-foreground mt-1">
Le regole collegano gli eventi PecFlow ai canali di notifica.
</p>
</div>
)
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Regola</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Evento</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Canale</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Stato</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Azioni</th>
</tr>
</thead>
<tbody className="divide-y">
{rules.map((rule) => (
<tr
key={rule.id}
className={cn('hover:bg-muted/30 transition-colors', !rule.is_active && 'opacity-60')}
>
<td className="px-4 py-3 font-medium">{rule.name}</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-1 rounded-full bg-violet-100 text-violet-800 px-2 py-0.5 text-xs font-medium">
<Zap className="h-3 w-3" />
{EVENT_TYPE_LABELS[rule.event_type as NotificationEventType] ?? rule.event_type}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{channelName(rule.channel_id)}</td>
<td className="px-4 py-3">
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
rule.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600',
)}
>
{rule.is_active ? 'Attiva' : 'Inattiva'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={rule.is_active ? 'Disattiva' : 'Attiva'}
onClick={() =>
toggleActiveMutation.mutate({ id: rule.id, active: !rule.is_active })
}
>
{rule.is_active ? (
<XCircle className="h-4 w-4 text-yellow-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Elimina"
onClick={() => {
if (confirm(`Eliminare la regola "${rule.name}"?`)) {
deleteMutation.mutate(rule.id)
}
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ─── Tab Log ──────────────────────────────────────────────────────────────────
function LogsTab() {
const { data: channelsData } = useQuery({
queryKey: ['notif-channels'],
queryFn: () => notificationsApi.listChannels({ page_size: 100 }),
})
const { data, isLoading } = useQuery({
queryKey: ['notif-logs'],
queryFn: () => notificationsApi.listLogs({ page_size: 50 }),
})
const channels = channelsData?.items ?? []
const logs = data?.items ?? []
const channelName = (id: string) =>
channels.find((c) => c.id === id)?.name ?? id.slice(0, 8) + '…'
if (isLoading)
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
if (logs.length === 0)
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Bell className="h-12 w-12 text-muted-foreground mb-4 opacity-40" />
<p className="text-lg font-medium text-muted-foreground">Nessun log</p>
<p className="text-sm text-muted-foreground mt-1">
I log appariranno qui quando verranno inviate notifiche.
</p>
</div>
)
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Canale</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Evento</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Stato</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Tentativi</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Data</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Errore</th>
</tr>
</thead>
<tbody className="divide-y">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium">{channelName(log.channel_id)}</td>
<td className="px-4 py-3 text-muted-foreground">
{EVENT_TYPE_LABELS[log.event_type as NotificationEventType] ?? log.event_type}
</td>
<td className="px-4 py-3">
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
STATUS_COLORS[log.status] ?? 'bg-gray-100 text-gray-600',
)}
>
{STATUS_LABELS[log.status] ?? log.status}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
{log.attempt_count}/{log.max_attempts}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{formatRelative(log.created_at)}
</td>
<td className="px-4 py-3 text-xs text-red-600 max-w-xs truncate">
{log.last_error || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ─── Dialog Canale ────────────────────────────────────────────────────────────
interface ChannelFormDialogProps {
open: boolean
editingChannel: NotificationChannelResponse | null
onClose: () => void
onSaved: () => void
}
interface ChannelFormValues {
name: string
channel_type: NotificationChannelType
// Webhook
webhook_url: string
webhook_secret: string
// Email
email_host: string
email_port: string
email_user: string
email_password: string
email_from: string
email_to: string
// Telegram
telegram_bot_token: string
telegram_chat_id: string
// WhatsApp
whatsapp_phone_number_id: string
whatsapp_access_token: string
whatsapp_to_number: string
}
function ChannelFormDialog({ open, editingChannel, onClose, onSaved }: ChannelFormDialogProps) {
const { register, watch, handleSubmit, formState: { errors } } = useForm<ChannelFormValues>({
defaultValues: {
name: editingChannel?.name ?? '',
channel_type: (editingChannel?.channel_type as NotificationChannelType) ?? 'webhook',
webhook_url: (editingChannel?.config?.url as string) ?? '',
email_host: (editingChannel?.config?.host as string) ?? '',
email_port: (editingChannel?.config?.port as string) ?? '465',
email_from: (editingChannel?.config?.from_email as string) ?? '',
email_to: (editingChannel?.config?.to_email as string) ?? '',
telegram_chat_id: (editingChannel?.config?.chat_id as string) ?? '',
whatsapp_phone_number_id: (editingChannel?.config?.phone_number_id as string) ?? '',
whatsapp_to_number: (editingChannel?.config?.to_number as string) ?? '',
},
})
const channelType = watch('channel_type')
const createMutation = useMutation({
mutationFn: (data: NotificationChannelCreate) => notificationsApi.createChannel(data),
onSuccess: () => { toast.success('Canale creato'); onSaved() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) =>
notificationsApi.updateChannel(id, data),
onSuccess: () => { toast.success('Canale aggiornato'); onSaved() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const onSubmit = (values: ChannelFormValues) => {
let config: Record<string, unknown> = {}
let config_secret: Record<string, unknown> | null = null
if (values.channel_type === 'webhook') {
config = { url: values.webhook_url }
if (values.webhook_secret) config_secret = { secret: values.webhook_secret }
} else if (values.channel_type === 'email') {
config = {
host: values.email_host,
port: parseInt(values.email_port),
from_email: values.email_from,
to_email: values.email_to,
}
if (values.email_user) config_secret = { username: values.email_user, password: values.email_password }
} else if (values.channel_type === 'telegram') {
config = { chat_id: values.telegram_chat_id }
if (values.telegram_bot_token) config_secret = { bot_token: values.telegram_bot_token }
} else if (values.channel_type === 'whatsapp') {
config = {
phone_number_id: values.whatsapp_phone_number_id,
to_number: values.whatsapp_to_number,
}
if (values.whatsapp_access_token) config_secret = { access_token: values.whatsapp_access_token }
}
if (editingChannel) {
updateMutation.mutate({
id: editingChannel.id,
data: { name: values.name, config, config_secret },
})
} else {
createMutation.mutate({
name: values.name,
channel_type: values.channel_type,
config,
config_secret,
})
}
}
const isPending = createMutation.isPending || updateMutation.isPending
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingChannel ? 'Modifica canale' : 'Nuovo canale di notifica'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Nome *</Label>
<Input {...register('name', { required: 'Obbligatorio' })} placeholder="es. Webhook produzione" />
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
{!editingChannel && (
<div className="space-y-2">
<Label>Tipo canale *</Label>
<select
className="w-full h-10 rounded-md border border-input bg-background px-3 text-sm"
{...register('channel_type')}
>
{(Object.entries(CHANNEL_TYPE_LABELS) as [NotificationChannelType, string][]).map(
([val, lbl]) => (
<option key={val} value={val}>{lbl}</option>
),
)}
</select>
</div>
)}
{/* Config per tipo */}
{channelType === 'webhook' && (
<>
<div className="space-y-2">
<Label>URL Webhook *</Label>
<Input
{...register('webhook_url', { required: 'Obbligatorio' })}
placeholder="https://example.com/webhook"
/>
</div>
<div className="space-y-2">
<Label>Segreto HMAC (opzionale)</Label>
<Input
{...register('webhook_secret')}
type="password"
placeholder="Secret per firma HMAC-SHA256"
/>
</div>
</>
)}
{channelType === 'email' && (
<>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2 space-y-2">
<Label>Host SMTP *</Label>
<Input {...register('email_host', { required: true })} placeholder="smtp.example.com" />
</div>
<div className="space-y-2">
<Label>Porta</Label>
<Input {...register('email_port')} placeholder="465" />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label>Username SMTP</Label>
<Input {...register('email_user')} />
</div>
<div className="space-y-2">
<Label>Password SMTP</Label>
<Input {...register('email_password')} type="password" />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label>Da *</Label>
<Input {...register('email_from', { required: true })} placeholder="noreply@..." />
</div>
<div className="space-y-2">
<Label>A *</Label>
<Input {...register('email_to', { required: true })} placeholder="destinatario@..." />
</div>
</div>
</>
)}
{channelType === 'telegram' && (
<>
<div className="space-y-2">
<Label>Bot Token *</Label>
<Input
{...register('telegram_bot_token', { required: true })}
type="password"
placeholder="123456:ABC-DEF…"
/>
</div>
<div className="space-y-2">
<Label>Chat ID *</Label>
<Input
{...register('telegram_chat_id', { required: true })}
placeholder="-100123456789"
/>
</div>
</>
)}
{channelType === 'whatsapp' && (
<>
<div className="space-y-2">
<Label>Phone Number ID *</Label>
<Input {...register('whatsapp_phone_number_id', { required: true })} />
</div>
<div className="space-y-2">
<Label>Access Token *</Label>
<Input {...register('whatsapp_access_token', { required: true })} type="password" />
</div>
<div className="space-y-2">
<Label>Numero destinatario *</Label>
<Input
{...register('whatsapp_to_number', { required: true })}
placeholder="+39..."
/>
</div>
</>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Annulla</Button>
<Button type="submit" isLoading={isPending}>
{editingChannel ? 'Salva' : 'Crea canale'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ─── Dialog Regola ────────────────────────────────────────────────────────────
interface RuleFormDialogProps {
open: boolean
onClose: () => void
onSaved: () => void
}
interface RuleFormValues {
name: string
channel_id: string
event_type: NotificationEventType
}
function RuleFormDialog({ open, onClose, onSaved }: RuleFormDialogProps) {
const { register, handleSubmit, formState: { errors } } = useForm<RuleFormValues>({
defaultValues: { name: '', channel_id: '', event_type: 'new_message' },
})
const { data: channelsData } = useQuery({
queryKey: ['notif-channels'],
queryFn: () => notificationsApi.listChannels({ page_size: 100 }),
})
const createMutation = useMutation({
mutationFn: (data: NotificationRuleCreate) => notificationsApi.createRule(data),
onSuccess: () => { toast.success('Regola creata'); onSaved() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const channels = channelsData?.items ?? []
const onSubmit = (values: RuleFormValues) => {
createMutation.mutate({
name: values.name,
channel_id: values.channel_id,
event_type: values.event_type,
})
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Nuova regola di notifica</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Nome regola *</Label>
<Input {...register('name', { required: 'Obbligatorio' })} placeholder="es. Avviso nuovo messaggio" />
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label>Canale *</Label>
<select
className="w-full h-10 rounded-md border border-input bg-background px-3 text-sm"
{...register('channel_id', { required: 'Obbligatorio' })}
>
<option value="">Seleziona canale</option>
{channels.map((c) => (
<option key={c.id} value={c.id}>
{CHANNEL_TYPE_LABELS[c.channel_type as NotificationChannelType]} {c.name}
</option>
))}
</select>
{errors.channel_id && (
<p className="text-xs text-destructive">{errors.channel_id.message}</p>
)}
</div>
<div className="space-y-2">
<Label>Evento *</Label>
<select
className="w-full h-10 rounded-md border border-input bg-background px-3 text-sm"
{...register('event_type', { required: true })}
>
{(Object.entries(EVENT_TYPE_LABELS) as [NotificationEventType, string][]).map(
([val, lbl]) => (
<option key={val} value={val}>{lbl}</option>
),
)}
</select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Annulla</Button>
<Button type="submit" isLoading={createMutation.isPending}>Crea regola</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,198 @@
/**
* SettingsPage impostazioni profilo dell'utente corrente.
*
* Sezioni:
* - Informazioni profilo (nome visualizzato, email, ruolo)
* - Modifica nome
* - Cambio password
*/
import { useState } from 'react'
import { Settings, User, Lock, Save } from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
import { useAuthStore } from '@/store/auth.store'
import { usersApi } from '@/api/users.api'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { Card } from '@/components/ui/Card'
import toast from 'react-hot-toast'
// ─── Etichetta ruolo ─────────────────────────────────────────────────────────
function roleLabel(role: string): string {
switch (role) {
case 'super_admin':
return 'Super Amministratore'
case 'admin':
return 'Amministratore'
case 'operator':
return 'Operatore'
default:
return role
}
}
// ─── Pagina ──────────────────────────────────────────────────────────────────
export function SettingsPage() {
const { user } = useAuth()
const loadUser = useAuthStore((s) => s.loadUser)
/* ── Stato modifica nome ── */
const [fullName, setFullName] = useState(user?.full_name ?? '')
const [savingName, setSavingName] = useState(false)
/* ── Stato cambio password ── */
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [savingPwd, setSavingPwd] = useState(false)
/* ── Salva nome ── */
const handleSaveName = async () => {
if (!user) return
if (!fullName.trim()) {
toast.error('Il nome non può essere vuoto')
return
}
setSavingName(true)
try {
await usersApi.update(user.id, { full_name: fullName.trim() })
await loadUser()
toast.success('Nome aggiornato con successo')
} catch {
toast.error('Errore durante il salvataggio del nome')
} finally {
setSavingName(false)
}
}
/* ── Cambia password ── */
const handleChangePassword = async () => {
if (!user) return
if (newPassword.length < 8) {
toast.error('La password deve essere di almeno 8 caratteri')
return
}
if (newPassword !== confirmPassword) {
toast.error('Le password non coincidono')
return
}
setSavingPwd(true)
try {
await usersApi.resetPassword(user.id, newPassword)
setNewPassword('')
setConfirmPassword('')
toast.success('Password aggiornata con successo')
} catch {
toast.error('Errore durante il cambio della password')
} finally {
setSavingPwd(false)
}
}
if (!user) return null
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
{/* ── Intestazione ── */}
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-blue-600 flex items-center justify-center">
<Settings className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">Impostazioni</h1>
<p className="text-sm text-gray-500">Gestisci il tuo profilo e le credenziali di accesso</p>
</div>
</div>
{/* ── Card: Informazioni account ── */}
<Card className="p-5 space-y-4">
<div className="flex items-center gap-2 mb-1">
<User className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Informazioni account
</h2>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Email</span>
<p className="mt-0.5 font-medium text-gray-800">{user.email}</p>
</div>
<div>
<span className="text-gray-500">Ruolo</span>
<p className="mt-0.5 font-medium text-gray-800">{roleLabel(user.role)}</p>
</div>
</div>
<hr className="border-gray-100" />
{/* Modifica nome */}
<div className="space-y-2">
<Label htmlFor="full_name">Nome visualizzato</Label>
<div className="flex gap-2">
<Input
id="full_name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Il tuo nome"
className="flex-1"
/>
<Button
onClick={handleSaveName}
disabled={savingName || fullName.trim() === (user.full_name ?? '')}
size="sm"
>
<Save className="h-4 w-4 mr-1.5" />
{savingName ? 'Salvataggio…' : 'Salva'}
</Button>
</div>
</div>
</Card>
{/* ── Card: Cambio password ── */}
<Card className="p-5 space-y-4">
<div className="flex items-center gap-2 mb-1">
<Lock className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Cambio password
</h2>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="new_password">Nuova password</Label>
<Input
id="new_password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Minimo 8 caratteri"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm_password">Conferma password</Label>
<Input
id="confirm_password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Ripeti la nuova password"
/>
</div>
<Button
onClick={handleChangePassword}
disabled={savingPwd || !newPassword || !confirmPassword}
>
<Lock className="h-4 w-4 mr-1.5" />
{savingPwd ? 'Aggiornamento…' : 'Aggiorna password'}
</Button>
</div>
</Card>
</div>
)
}
@@ -0,0 +1,803 @@
/**
* Pagina Virtual Box gestione filtri nominati assegnabili agli utenti.
*
* Struttura:
* - Tabella delle VBox con nome, label, n° regole, n° utenti, stato, caselle PEC
* - Dialog creazione/modifica con builder di regole e selezione caselle reali
* - Dialog assegnazione utenti
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Filter,
Inbox,
Plus,
Edit,
Trash2,
Users,
ChevronDown,
ChevronUp,
CheckCircle,
XCircle,
Tag,
} from 'lucide-react'
import { useForm, useFieldArray } from 'react-hook-form'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog'
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { usersApi } from '@/api/users.api'
import { getErrorMessage } from '@/api/client'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import type {
AssignedUserResponse,
VirtualBoxCreate,
VirtualBoxResponse,
VirtualBoxRuleCreate,
VBoxField,
VBoxOperator,
} from '@/types/api.types'
// ─── Label/costanti ───────────────────────────────────────────────────────────
const FIELD_LABELS: Record<VBoxField, string> = {
mailbox_id: 'Casella PEC (ID)',
imap_folder: 'Cartella IMAP',
subject: 'Oggetto',
from_address: 'Mittente',
to_address: 'Destinatario',
}
const OPERATOR_LABELS: Record<VBoxOperator, string> = {
contains: 'contiene',
equals: 'uguale a',
starts_with: 'inizia per',
ends_with: 'finisce per',
regex: 'regex',
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function VirtualBoxesPage() {
const queryClient = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [editingVbox, setEditingVbox] = useState<VirtualBoxResponse | null>(null)
const [assigningVbox, setAssigningVbox] = useState<VirtualBoxResponse | null>(null)
const [expandedRules, setExpandedRules] = useState<Set<string>>(new Set())
const { data, isLoading } = useQuery({
queryKey: ['virtual-boxes'],
queryFn: () => virtualBoxesApi.list({ page_size: 100 }),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => virtualBoxesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['virtual-boxes'] })
toast.success('Virtual Box eliminata')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggleActive = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) =>
virtualBoxesApi.update(id, { is_active: active }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['virtual-boxes'] })
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const vboxes = data?.items ?? []
const toggleRules = (id: string) => {
setExpandedRules((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-primary" />
<h1 className="text-xl font-semibold">Virtual Box</h1>
<span className="text-sm text-muted-foreground">
({vboxes.filter((v) => v.is_active).length} attive su {vboxes.length})
</span>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-2" />
Nuova Virtual Box
</Button>
</div>
{/* Descrizione */}
<div className="px-6 py-3 bg-blue-50 border-b text-sm text-blue-800">
<strong>Cos'è una Virtual Box?</strong> Un filtro nominato (oggetto, mittente, cartella,
data…) assegnabile a uno o più utenti e collegato a una o più{' '}
<strong>caselle PEC reali</strong>. Le regole nella stessa VBox si combinano in{' '}
<strong>AND</strong>; più VBox assegnate allo stesso utente si uniscono in{' '}
<strong>OR</strong>. Il filtro si applica automaticamente a inbox e ricerca.
</div>
{/* Contenuto */}
<div className="flex-1 overflow-auto p-6">
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : vboxes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Filter className="h-12 w-12 text-muted-foreground mb-4 opacity-40" />
<p className="text-lg font-medium text-muted-foreground">Nessuna Virtual Box</p>
<p className="text-sm text-muted-foreground mt-1">
Crea la prima Virtual Box per filtrare i messaggi visibili agli utenti.
</p>
<Button className="mt-4" onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-2" />
Crea Virtual Box
</Button>
</div>
) : (
<div className="space-y-3">
{vboxes.map((vbox) => (
<VBoxCard
key={vbox.id}
vbox={vbox}
rulesExpanded={expandedRules.has(vbox.id)}
onToggleRules={() => toggleRules(vbox.id)}
onEdit={() => setEditingVbox(vbox)}
onAssign={() => setAssigningVbox(vbox)}
onDelete={() => {
if (confirm(`Eliminare la Virtual Box "${vbox.name}"?`)) {
deleteMutation.mutate(vbox.id)
}
}}
onToggleActive={() =>
toggleActive.mutate({ id: vbox.id, active: !vbox.is_active })
}
/>
))}
</div>
)}
</div>
{/* Dialogs */}
{(showCreate || editingVbox) && (
<VBoxFormDialog
open
editingVbox={editingVbox}
onClose={() => {
setShowCreate(false)
setEditingVbox(null)
}}
onSaved={() => {
queryClient.invalidateQueries({ queryKey: ['virtual-boxes'] })
setShowCreate(false)
setEditingVbox(null)
}}
/>
)}
{assigningVbox && (
<AssignUsersDialog
vbox={assigningVbox}
onClose={() => setAssigningVbox(null)}
onSaved={() => {
queryClient.invalidateQueries({ queryKey: ['virtual-boxes'] })
setAssigningVbox(null)
}}
/>
)}
</div>
)
}
// ─── Card singola VBox ────────────────────────────────────────────────────────
interface VBoxCardProps {
vbox: VirtualBoxResponse
rulesExpanded: boolean
onToggleRules: () => void
onEdit: () => void
onAssign: () => void
onDelete: () => void
onToggleActive: () => void
}
function VBoxCard({
vbox,
rulesExpanded,
onToggleRules,
onEdit,
onAssign,
onDelete,
onToggleActive,
}: VBoxCardProps) {
return (
<div
className={cn(
'rounded-lg border bg-card transition-colors',
!vbox.is_active && 'opacity-60',
)}
>
{/* Intestazione */}
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{vbox.name}</span>
{vbox.label && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 text-blue-800 px-2 py-0.5 text-xs">
<Tag className="h-3 w-3" />
{vbox.label}
</span>
)}
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
vbox.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600',
)}
>
{vbox.is_active ? 'Attiva' : 'Inattiva'}
</span>
</div>
{vbox.description && (
<p className="text-xs text-muted-foreground mt-0.5">{vbox.description}</p>
)}
{/* Caselle PEC associate */}
{vbox.mailboxes.length > 0 ? (
<div className="flex flex-wrap gap-1 mt-1.5">
{vbox.mailboxes.map((mb) => (
<span
key={mb.id}
className="inline-flex items-center gap-1 rounded-full bg-indigo-100 text-indigo-800 px-2 py-0.5 text-xs"
>
<Inbox className="h-3 w-3" />
{mb.display_name ?? mb.email_address}
</span>
))}
</div>
) : (
<p className="text-xs text-amber-600 mt-1">
⚠ Nessuna casella PEC associata
</p>
)}
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-xs text-muted-foreground shrink-0">
<span className="flex items-center gap-1">
<Filter className="h-3.5 w-3.5" />
{vbox.rules.length} {vbox.rules.length === 1 ? 'regola' : 'regole'}
</span>
<span className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{vbox.assignment_count} {vbox.assignment_count === 1 ? 'utente' : 'utenti'}
</span>
<span>{formatDate(vbox.updated_at)}</span>
</div>
{/* Azioni */}
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" title="Assegna utenti" onClick={onAssign}>
<Users className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Modifica" onClick={onEdit}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={vbox.is_active ? 'Disattiva' : 'Attiva'}
onClick={onToggleActive}
>
{vbox.is_active ? (
<XCircle className="h-4 w-4 text-yellow-500" />
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Elimina"
onClick={onDelete}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
{vbox.rules.length > 0 && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={rulesExpanded ? 'Nascondi regole' : 'Mostra regole'}
onClick={onToggleRules}
>
{rulesExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
{/* Regole espanse */}
{rulesExpanded && vbox.rules.length > 0 && (
<div className="border-t px-4 py-3">
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
Regole (combinate in AND)
</p>
<div className="space-y-1.5">
{vbox.rules.map((rule, i) => (
<div
key={rule.id}
className="flex items-center gap-2 text-sm bg-muted/40 rounded px-3 py-1.5"
>
<span className="text-xs text-muted-foreground w-4">{i + 1}.</span>
<span className="font-medium">{FIELD_LABELS[rule.field as VBoxField] ?? rule.field}</span>
<span className="text-muted-foreground">
{OPERATOR_LABELS[rule.operator as VBoxOperator] ?? rule.operator}
</span>
<span className="font-mono bg-background rounded px-1.5 py-0.5 text-xs border">
{rule.value}
</span>
{(rule.date_from || rule.date_to) && (
<span className="text-muted-foreground text-xs ml-2">
{rule.date_from && `dal ${rule.date_from}`}
{rule.date_from && rule.date_to && ' '}
{rule.date_to && `al ${rule.date_to}`}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
// ─── Dialog crea/modifica VBox ────────────────────────────────────────────────
interface VBoxFormValues {
name: string
description: string
label: string
mailbox_ids: string[]
rules: Array<{
field: VBoxField
operator: VBoxOperator
value: string
date_from: string
date_to: string
}>
}
interface VBoxFormDialogProps {
open: boolean
editingVbox: VirtualBoxResponse | null
onClose: () => void
onSaved: () => void
}
function VBoxFormDialog({ open, editingVbox, onClose, onSaved }: VBoxFormDialogProps) {
const { register, control, handleSubmit, watch, setValue, formState: { errors } } =
useForm<VBoxFormValues>({
defaultValues: editingVbox
? {
name: editingVbox.name,
description: editingVbox.description ?? '',
label: editingVbox.label ?? '',
mailbox_ids: editingVbox.mailboxes.map((m) => m.id),
rules: editingVbox.rules.map((r) => ({
field: r.field as VBoxField,
operator: r.operator as VBoxOperator,
value: r.value,
date_from: r.date_from ?? '',
date_to: r.date_to ?? '',
})),
}
: {
name: '',
description: '',
label: '',
mailbox_ids: [],
rules: [],
},
})
const { fields, append, remove } = useFieldArray({ control, name: 'rules' })
const selectedMailboxIds = watch('mailbox_ids')
// Carica le mailbox disponibili
const { data: mailboxesData } = useQuery({
queryKey: ['mailboxes-brief'],
queryFn: () => mailboxesApi.list(1, 100),
})
const availableMailboxes = mailboxesData?.items ?? []
const toggleMailbox = (id: string) => {
const current = selectedMailboxIds ?? []
if (current.includes(id)) {
setValue('mailbox_ids', current.filter((x) => x !== id))
} else {
setValue('mailbox_ids', [...current, id])
}
}
const createMutation = useMutation({
mutationFn: (data: VirtualBoxCreate) => virtualBoxesApi.create(data),
onSuccess: () => { toast.success('Virtual Box creata'); onSaved() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: ({ id, rules }: { id: string; rules: VirtualBoxRuleCreate[] }) =>
virtualBoxesApi.replaceRules(id, rules),
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMetaMutation = useMutation({
mutationFn: ({ id, data, rules }: { id: string; data: any; rules: VirtualBoxRuleCreate[] }) =>
virtualBoxesApi.update(id, data),
onSuccess: async (_, vars) => {
await updateMutation.mutateAsync({ id: vars.id, rules: vars.rules })
toast.success('Virtual Box aggiornata')
onSaved()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const onSubmit = (data: VBoxFormValues) => {
const rules: VirtualBoxRuleCreate[] = data.rules.map((r) => ({
field: r.field,
operator: r.operator,
value: r.value,
date_from: r.date_from || null,
date_to: r.date_to || null,
}))
if (editingVbox) {
updateMetaMutation.mutate({
id: editingVbox.id,
data: {
name: data.name,
description: data.description || null,
label: data.label || null,
mailbox_ids: data.mailbox_ids,
},
rules,
})
} else {
createMutation.mutate({
name: data.name,
description: data.description || null,
label: data.label || null,
rules,
mailbox_ids: data.mailbox_ids,
})
}
}
const isPending = createMutation.isPending || updateMetaMutation.isPending
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingVbox ? 'Modifica Virtual Box' : 'Nuova Virtual Box'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5 mt-4">
{/* Metadati */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2 col-span-2">
<Label>Nome *</Label>
<Input
{...register('name', { required: 'Obbligatorio' })}
placeholder="es. Multe da info@comune.it"
/>
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-2">
<Label>Etichetta</Label>
<Input
{...register('label')}
placeholder="es. Comune, Fornitori…"
/>
</div>
<div className="space-y-2">
<Label>Descrizione</Label>
<Input
{...register('description')}
placeholder="Descrizione opzionale…"
/>
</div>
</div>
{/* Caselle PEC reali */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Inbox className="h-4 w-4 text-primary" />
<Label>Caselle PEC reali *</Label>
</div>
{availableMailboxes.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4 border-2 border-dashed rounded-lg">
Nessuna casella PEC disponibile. Configura prima una casella reale.
</p>
) : (
<div className="grid grid-cols-1 gap-2 max-h-44 overflow-y-auto border rounded-lg p-3">
{availableMailboxes.map((mb) => {
const checked = (selectedMailboxIds ?? []).includes(mb.id)
return (
<label
key={mb.id}
className={cn(
'flex items-center gap-3 p-2.5 rounded-md border cursor-pointer transition-colors',
checked
? 'border-primary bg-primary/5'
: 'border-transparent hover:bg-muted/50',
)}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleMailbox(mb.id)}
className="h-4 w-4 accent-primary"
/>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{mb.email_address}</p>
{mb.display_name && (
<p className="text-xs text-muted-foreground truncate">{mb.display_name}</p>
)}
</div>
</label>
)
})}
</div>
)}
{(selectedMailboxIds ?? []).length === 0 && availableMailboxes.length > 0 && (
<p className="text-xs text-amber-600">
⚠ Seleziona almeno una casella PEC reale da associare.
</p>
)}
</div>
{/* Regole */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Regole di filtro (combinate in AND)</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({ field: 'subject', operator: 'contains', value: '', date_from: '', date_to: '' })
}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Aggiungi regola
</Button>
</div>
{fields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4 border-2 border-dashed rounded-lg">
Nessuna regola. La VBox mostrerà tutti i messaggi delle caselle selezionate.
</p>
) : (
<div className="space-y-2">
{fields.map((field, index) => (
<div key={field.id} className="flex items-start gap-2 p-3 border rounded-lg bg-muted/30">
<div className="grid grid-cols-3 gap-2 flex-1">
{/* Campo */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Campo</label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm"
{...register(`rules.${index}.field`)}
>
{(Object.entries(FIELD_LABELS) as [VBoxField, string][]).map(
([val, lbl]) => (
<option key={val} value={val}>{lbl}</option>
),
)}
</select>
</div>
{/* Operatore */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Operatore</label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm"
{...register(`rules.${index}.operator`)}
>
{(Object.entries(OPERATOR_LABELS) as [VBoxOperator, string][]).map(
([val, lbl]) => (
<option key={val} value={val}>{lbl}</option>
),
)}
</select>
</div>
{/* Valore */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Valore</label>
<Input
{...register(`rules.${index}.value`, { required: true })}
placeholder="es. info@comune.it"
className="h-9"
/>
</div>
{/* Date range */}
<div>
<label className="text-xs text-muted-foreground mb-1 block">Dal (opz.)</label>
<Input type="date" {...register(`rules.${index}.date_from`)} className="h-9" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">Al (opz.)</label>
<Input type="date" {...register(`rules.${index}.date_to`)} className="h-9" />
</div>
</div>
<button
type="button"
onClick={() => remove(index)}
className="mt-5 p-1.5 rounded hover:bg-destructive/10 text-destructive"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Annulla</Button>
<Button type="submit" isLoading={isPending}>
{editingVbox ? 'Salva' : 'Crea Virtual Box'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ─── Dialog assegnazione utenti ───────────────────────────────────────────────
interface AssignUsersDialogProps {
vbox: VirtualBoxResponse
onClose: () => void
onSaved: () => void
}
function AssignUsersDialog({ vbox, onClose, onSaved }: AssignUsersDialogProps) {
const queryClient = useQueryClient()
const { data: usersData } = useQuery({
queryKey: ['users'],
queryFn: () => usersApi.list(1, 100),
})
const { data: assignedData, isLoading: loadingAssigned } = useQuery({
queryKey: ['vbox-assignments', vbox.id],
queryFn: () => virtualBoxesApi.listAssignedUsers(vbox.id),
})
const assignedIds = new Set((assignedData ?? []).map((a: AssignedUserResponse) => a.user_id))
const users = (usersData?.items ?? []).filter(
(u) => !u.is_active === false && !['super_admin', 'admin'].includes(u.role),
)
const assignMutation = useMutation({
mutationFn: (userId: string) =>
virtualBoxesApi.assignUsers(vbox.id, { user_ids: [userId] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vbox-assignments', vbox.id] })
toast.success('Utente assegnato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const unassignMutation = useMutation({
mutationFn: (userId: string) => virtualBoxesApi.unassignUser(vbox.id, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vbox-assignments', vbox.id] })
toast.success('Assegnazione rimossa')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Assegna utenti {vbox.name}</DialogTitle>
</DialogHeader>
{/* Riepilogo caselle */}
{vbox.mailboxes.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
<span className="text-xs text-muted-foreground mr-1">Caselle:</span>
{vbox.mailboxes.map((mb) => (
<span
key={mb.id}
className="inline-flex items-center gap-1 rounded-full bg-indigo-100 text-indigo-800 px-2 py-0.5 text-xs"
>
<Inbox className="h-3 w-3" />
{mb.display_name ?? mb.email_address}
</span>
))}
</div>
)}
<div className="mt-4 space-y-2 max-h-80 overflow-y-auto">
{loadingAssigned ? (
<div className="flex justify-center py-6">
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : users.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
Nessun operatore disponibile
</p>
) : (
users.map((user) => {
const isAssigned = assignedIds.has(user.id)
return (
<div
key={user.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="text-sm font-medium">{user.full_name}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
<Button
variant={isAssigned ? 'destructive' : 'outline'}
size="sm"
onClick={() => {
if (isAssigned) unassignMutation.mutate(user.id)
else assignMutation.mutate(user.id)
}}
isLoading={assignMutation.isPending || unassignMutation.isPending}
>
{isAssigned ? 'Rimuovi' : 'Assegna'}
</Button>
</div>
)
})
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Chiudi</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+197
View File
@@ -283,6 +283,203 @@ export interface UserMailboxPermissionResponse {
can_manage: boolean
}
// ─── Virtual Box ──────────────────────────────────────────────────────────────
export type VBoxField = 'mailbox_id' | 'imap_folder' | 'subject' | 'from_address' | 'to_address'
export type VBoxOperator = 'contains' | 'equals' | 'starts_with' | 'ends_with' | 'regex'
export interface VirtualBoxRuleCreate {
field: VBoxField
operator: VBoxOperator
value: string
date_from?: string | null
date_to?: string | null
}
export interface VirtualBoxRuleResponse {
id: string
virtual_box_id: string
field: string
operator: string
value: string
date_from: string | null
date_to: string | null
created_at: string
}
export interface MailboxBriefResponse {
id: string
email_address: string
display_name: string | null
}
export interface VirtualBoxCreate {
name: string
description?: string | null
label?: string | null
rules?: VirtualBoxRuleCreate[]
mailbox_ids?: string[]
}
export interface VirtualBoxUpdate {
name?: string
description?: string | null
label?: string | null
is_active?: boolean
mailbox_ids?: string[] | null
}
export interface VirtualBoxMailboxAssignRequest {
mailbox_ids: string[]
}
export interface VirtualBoxResponse {
id: string
tenant_id: string
name: string
description: string | null
label: string | null
is_active: boolean
created_by: string | null
created_at: string
updated_at: string
rules: VirtualBoxRuleResponse[]
assignment_count: number
mailboxes: MailboxBriefResponse[]
}
export interface VirtualBoxListResponse {
items: VirtualBoxResponse[]
total: number
page: number
page_size: number
}
export interface VirtualBoxAssignRequest {
user_ids: string[]
}
export interface VirtualBoxAssignmentResponse {
id: string
virtual_box_id: string
user_id: string
assigned_by: string | null
assigned_at: string
}
export interface AssignedUserResponse {
user_id: string
user_email: string
user_full_name: string
assigned_at: string
}
// ─── Notifications ────────────────────────────────────────────────────────────
export type NotificationChannelType = 'webhook' | 'email' | 'telegram' | 'whatsapp'
export type NotificationEventType =
| 'new_message'
| 'state_changed'
| 'anomaly'
| 'send_failed'
| 'send_delivered'
| 'mailbox_error'
export type NotificationStatus = 'pending' | 'sent' | 'failed' | 'skipped'
export interface NotificationChannelCreate {
name: string
channel_type: NotificationChannelType
config?: Record<string, unknown> | null
config_secret?: Record<string, unknown> | null
}
export interface NotificationChannelUpdate {
name?: string
is_active?: boolean
config?: Record<string, unknown> | null
config_secret?: Record<string, unknown> | null
}
export interface NotificationChannelResponse {
id: string
tenant_id: string
name: string
channel_type: NotificationChannelType
is_active: boolean
config: Record<string, unknown> | null
consecutive_failures: number
circuit_open_until: string | null
created_by: string | null
created_at: string
updated_at: string
}
export interface NotificationChannelListResponse {
items: NotificationChannelResponse[]
total: number
page: number
page_size: number
}
export interface ChannelTestResult {
success: boolean
message: string
http_status: number | null
}
export interface NotificationRuleCreate {
channel_id: string
name: string
event_type: NotificationEventType
filter?: Record<string, unknown> | null
}
export interface NotificationRuleUpdate {
name?: string
event_type?: NotificationEventType
filter?: Record<string, unknown> | null
is_active?: boolean
}
export interface NotificationRuleResponse {
id: string
tenant_id: string
channel_id: string
name: string
event_type: string
filter: Record<string, unknown> | null
is_active: boolean
created_at: string
}
export interface NotificationRuleListResponse {
items: NotificationRuleResponse[]
total: number
}
export interface NotificationLogResponse {
id: string
tenant_id: string
channel_id: string
rule_id: string | null
event_type: string
status: NotificationStatus
attempt_count: number
max_attempts: number
next_retry_at: string | null
last_error: string | null
http_status: number | null
sent_at: string | null
created_at: string
}
export interface NotificationLogListResponse {
items: NotificationLogResponse[]
total: number
page: number
page_size: number
}
// ─── WebSocket events ─────────────────────────────────────────────────────────
export type WsEventType =