mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
vbox funzionanti
This commit is contained in:
+45
-18
@@ -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 />} />
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user