Conservazionee

This commit is contained in:
2026-03-27 16:54:49 +01:00
parent e390d344ff
commit 047990811f
12 changed files with 466 additions and 118 deletions
+14 -10
View File
@@ -47,18 +47,22 @@ export default function App() {
<Route path="/" element={<Navigate to="/inbox" replace />} />
{/* 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" />} />
<Route path="/trash" element={<InboxPage viewMode="trash" />} />
<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" />} />
<Route path="/trash" element={<InboxPage viewMode="trash" />} />
<Route path="/conservation-pending" element={<InboxPage viewMode="conservation_pending" />} />
<Route path="/conservation-archived" element={<InboxPage viewMode="conservation_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" />} />
<Route path="/mailbox/:mailboxId/trash" element={<InboxPage viewMode="trash" />} />
<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" />} />
<Route path="/mailbox/:mailboxId/trash" element={<InboxPage viewMode="trash" />} />
<Route path="/mailbox/:mailboxId/conservation-pending" element={<InboxPage viewMode="conservation_pending" />} />
<Route path="/mailbox/:mailboxId/conservation-archived" element={<InboxPage viewMode="conservation_archived" />} />
{/* Vista per Virtual Box assegnata */}
<Route path="/virtual-box/:vboxId/inbox" element={<InboxPage viewMode="inbox" />} />
+18
View File
@@ -18,6 +18,10 @@ export interface MessageFilters {
is_starred?: boolean
is_archived?: boolean
is_trashed?: boolean
/** Filtra per messaggi in attesa di conservazione (cartella Da Conservare) */
is_pending_conservation?: boolean
/** Filtra per messaggi gia' conservati (cartella Storico) */
is_conserved?: boolean
search?: string
/** Data minima nel formato ISO 8601 (es. "2026-01-01T00:00:00Z") */
date_from?: string
@@ -31,6 +35,8 @@ export interface MessageBulkUpdatePayload {
is_starred?: boolean
is_archived?: boolean
is_trashed?: boolean
is_pending_conservation?: boolean
is_conserved?: boolean
}
export interface MessageBulkUpdateResponse {
@@ -78,6 +84,18 @@ export const messagesApi = {
.patch<MessageResponse>(`/messages/${id}`, { is_trashed: false })
.then((r) => r.data),
/** Sposta un messaggio nella cartella Da Conservare */
conserve: (id: string) =>
apiClient
.patch<MessageResponse>(`/messages/${id}`, { is_pending_conservation: true })
.then((r) => r.data),
/** Rimuove un messaggio dalla cartella Da Conservare */
unconserve: (id: string) =>
apiClient
.patch<MessageResponse>(`/messages/${id}`, { is_pending_conservation: false })
.then((r) => r.data),
/** Aggiorna in blocco is_starred e/o is_archived e/o is_trashed su più messaggi */
bulkUpdate: (payload: MessageBulkUpdatePayload) =>
apiClient
@@ -53,6 +53,7 @@ import {
Search,
BarChart2,
ClipboardList,
ShieldCheck,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
@@ -297,6 +298,53 @@ export function Sidebar() {
<Trash2 className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Cestino</span>}
</NavLink>
{/* ── Sezione Conservazione (admin e supervisor) ── */}
{(isAdmin || isSupervisor) && (
<>
{!collapsed && (
<div className="pt-1 pb-0.5 px-1">
<p className="text-[10px] font-semibold text-teal-500/70 uppercase tracking-wider">
Conservazione
</p>
</div>
)}
<NavLink
to="/conservation-pending"
end
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-teal-700 text-white'
: 'text-teal-300 hover:bg-teal-900/40 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Da Conservare' : undefined}
>
<ShieldCheck className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Da Conservare</span>}
</NavLink>
<NavLink
to="/conservation-archived"
end
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-teal-700 text-white'
: 'text-teal-300 hover:bg-teal-900/40 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Storico Conservazione' : undefined}
>
<ShieldCheck className="h-4 w-4 flex-shrink-0 opacity-60" />
{!collapsed && <span>Storico</span>}
</NavLink>
</>
)}
</div>
</div>
@@ -604,6 +652,8 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle, unreadCount
const displayName = mailbox.display_name || mailbox.email_address
const initial = displayName[0]?.toUpperCase() ?? '?'
const dotClass = statusDot(mailbox.status)
const { isAdmin, isSupervisor } = useAuth()
const canSeeConservation = isAdmin || isSupervisor
/* ── Modalità compressa: solo avatar/iniziale → link diretto all'inbox ── */
if (collapsed) {
@@ -759,6 +809,40 @@ function MailboxNavItem({ mailbox, collapsed, isExpanded, onToggle, unreadCount
<Trash2 className="h-3.5 w-3.5 flex-shrink-0" />
<span>Cestino</span>
</NavLink>
{/* Conservazione (solo admin/supervisor) */}
{canSeeConservation && (
<>
<NavLink
to={`/mailbox/${mailbox.id}/conservation-pending`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-teal-700 text-white'
: 'text-teal-400 hover:bg-teal-900/40 hover:text-white',
)
}
>
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0" />
<span>Da Conservare</span>
</NavLink>
<NavLink
to={`/mailbox/${mailbox.id}/conservation-archived`}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-medium transition-colors',
isActive
? 'bg-teal-700 text-white'
: 'text-teal-400 hover:bg-teal-900/40 hover:text-white',
)
}
>
<ShieldCheck className="h-3.5 w-3.5 flex-shrink-0 opacity-60" />
<span>Storico</span>
</NavLink>
</>
)}
</div>
)}
</div>
+119 -19
View File
@@ -39,6 +39,8 @@ import {
SlidersHorizontal,
ChevronDown,
ChevronUp,
ShieldCheck,
ShieldX,
} from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
@@ -47,6 +49,7 @@ import { Input } from '@/components/ui/Input'
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
import { TagBadgeList } from '@/components/TagManager/TagBadge'
import { TagSelector } from '@/components/TagManager/TagSelector'
import { useAuth } from '@/hooks/useAuth'
import { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api'
import { mailboxesApi } from '@/api/mailboxes.api'
@@ -58,7 +61,7 @@ import { getErrorMessage } from '@/api/client'
// ─── Props ────────────────────────────────────────────────────────────────────
export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' | 'trash'
export type InboxViewMode = 'inbox' | 'sent' | 'starred' | 'archived' | 'trash' | 'conservation_pending' | 'conservation_archived'
interface InboxPageProps {
viewMode: InboxViewMode
@@ -75,6 +78,10 @@ export function InboxPage({ viewMode }: InboxPageProps) {
// true = stiamo navigando in una casella reale (non virtual box)
const isMailboxMode = !vboxId
// ── Ruolo utente per visibilita' pulsante Conservazione ─────────────────────
const { isAdmin, isSupervisor } = useAuth()
const canConserve = isAdmin || isSupervisor
// ── Stato filtri locale ──────────────────────────────────────────────────────
const [searchInput, setSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
@@ -196,6 +203,16 @@ export function InboxPage({ viewMode }: InboxPageProps) {
...advancedBase,
is_trashed: true,
}
case 'conservation_pending':
return {
...advancedBase,
is_pending_conservation: true,
}
case 'conservation_archived':
return {
...advancedBase,
is_conserved: true,
}
}
})()
@@ -310,6 +327,24 @@ export function InboxPage({ viewMode }: InboxPageProps) {
onError: (error) => toast.error(getErrorMessage(error)),
})
// ── Conservazione/Rimozione singolo ───────────────────────────────────────────
const conserveMutation = useMutation({
mutationFn: ({ id, conserve }: { id: string; conserve: boolean }) =>
conserve ? messagesApi.conserve(id) : messagesApi.unconserve(id),
onSuccess: (updatedMsg, { conserve }) => {
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(conserve ? 'Messaggio inviato in Da Conservare' : 'Messaggio rimosso da Da Conservare')
invalidateMessages()
},
onError: (error) => toast.error(getErrorMessage(error)),
})
// ── Azioni bulk ──────────────────────────────────────────────────────────────
const bulkMutation = useMutation({
mutationFn: messagesApi.bulkUpdate,
@@ -324,6 +359,8 @@ export function InboxPage({ viewMode }: InboxPageProps) {
else if (payload.is_archived === false) toast.success(`${n} ${n === 1 ? 'messaggio ripristinato' : 'messaggi ripristinati'}`)
else if (payload.is_trashed === true) toast.success(`${n} ${n === 1 ? 'messaggio spostato nel cestino' : 'messaggi spostati nel cestino'}`)
else if (payload.is_trashed === false) toast.success(`${n} ${n === 1 ? 'messaggio ripristinato dal cestino' : 'messaggi ripristinati dal cestino'}`)
else if (payload.is_pending_conservation === true) toast.success(`${n} ${n === 1 ? 'messaggio inviato' : 'messaggi inviati'} in Da Conservare`)
else if (payload.is_pending_conservation === false) toast.success(`${n} ${n === 1 ? 'messaggio rimosso' : 'messaggi rimossi'} da Da Conservare`)
},
onError: (error) => toast.error(getErrorMessage(error)),
})
@@ -367,6 +404,10 @@ export function InboxPage({ viewMode }: InboxPageProps) {
bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: true })
const handleBulkUntrash = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_trashed: false })
const handleBulkConserve = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_pending_conservation: true })
const handleBulkUnconserve = () =>
bulkMutation.mutate({ ids: Array.from(selectedIds), is_pending_conservation: false })
// ── Selezione ────────────────────────────────────────────────────────────────
const handleToggleSelect = (id: string, e: React.MouseEvent) => {
@@ -409,25 +450,20 @@ export function InboxPage({ viewMode }: InboxPageProps) {
// ── Label e icone folder ────────────────────────────────────────────────────
const isInbound = viewMode === 'inbox'
const folderLabel =
viewMode === 'inbox'
? 'Posta in Arrivo'
: viewMode === 'sent'
? 'Posta Inviata'
: viewMode === 'starred'
? 'Preferiti'
: viewMode === 'archived'
? 'Archiviati'
: 'Cestino'
viewMode === 'inbox' ? 'Posta in Arrivo'
: viewMode === 'sent' ? 'Posta Inviata'
: viewMode === 'starred' ? 'Preferiti'
: viewMode === 'archived' ? 'Archiviati'
: viewMode === 'trash' ? 'Cestino'
: viewMode === 'conservation_pending' ? 'Da Conservare'
: 'Storico Conservazione'
const FolderIcon =
viewMode === 'inbox'
? Inbox
: viewMode === 'sent'
? Send
: viewMode === 'starred'
? Star
: viewMode === 'archived'
? Archive
: Trash2
viewMode === 'inbox' ? Inbox
: viewMode === 'sent' ? Send
: viewMode === 'starred' ? Star
: viewMode === 'archived' ? Archive
: viewMode === 'trash' ? Trash2
: ShieldCheck
const selectedCount = selectedIds.size
const allSelected = messages.length > 0 && selectedCount === messages.length
@@ -760,6 +796,34 @@ export function InboxPage({ viewMode }: InboxPageProps) {
Tag
</Button>
)}
{/* Invia a Conservazione (solo admin/supervisor, non nelle viste conservazione) */}
{canConserve && viewMode !== 'conservation_pending' && viewMode !== 'conservation_archived' && viewMode !== 'trash' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-teal-200 hover:bg-teal-50 dark:border-teal-800 text-teal-700"
onClick={handleBulkConserve}
isLoading={bulkMutation.isPending}
>
<ShieldCheck className="h-3.5 w-3.5 mr-1" />
Invia a Conservazione
</Button>
)}
{/* Rimuovi da Da Conservare */}
{canConserve && viewMode === 'conservation_pending' && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-orange-200 hover:bg-orange-50 dark:border-orange-800 text-orange-700"
onClick={handleBulkUnconserve}
isLoading={bulkMutation.isPending}
>
<ShieldX className="h-3.5 w-3.5 mr-1" />
Rimuovi da Da Conservare
</Button>
)}
</div>
)}
@@ -818,6 +882,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
message={message}
viewMode={viewMode}
isMailboxMode={isMailboxMode}
canConserve={canConserve}
isSelected={selectedIds.has(message.id)}
onSelect={(e) => handleToggleSelect(message.id, e)}
onClick={() => handleMessageClick(message)}
@@ -837,6 +902,10 @@ export function InboxPage({ viewMode }: InboxPageProps) {
e.stopPropagation()
trashMutation.mutate({ id: message.id, trashed: !message.is_trashed })
}}
onToggleConserve={(e) => {
e.stopPropagation()
conserveMutation.mutate({ id: message.id, conserve: !message.is_pending_conservation })
}}
mailboxName={
!mailboxId
? mailboxesData?.items.find((m) => m.id === message.mailbox_id)?.email_address
@@ -897,12 +966,14 @@ interface MessageRowProps {
viewMode: InboxViewMode
isMailboxMode: boolean
isSelected: boolean
canConserve: boolean
onSelect: (e: React.MouseEvent) => void
onClick: () => void
onToggleStar: (e: React.MouseEvent) => void
onToggleArchive: (e: React.MouseEvent) => void
onMarkUnread: (e: React.MouseEvent) => void
onToggleTrash: (e: React.MouseEvent) => void
onToggleConserve: (e: React.MouseEvent) => void
mailboxName?: string
}
@@ -911,12 +982,14 @@ function MessageRow({
viewMode,
isMailboxMode,
isSelected,
canConserve,
onSelect,
onClick,
onToggleStar,
onToggleArchive,
onMarkUnread,
onToggleTrash,
onToggleConserve,
mailboxName,
}: MessageRowProps) {
const [hovered, setHovered] = useState(false)
@@ -1093,6 +1166,33 @@ function MessageRow({
</button>
)}
{/* Invia a Conservazione / Rimuovi da Da Conservare */}
{canConserve && viewMode !== 'trash' && viewMode !== 'conservation_archived' && (
<button
onClick={onToggleConserve}
title={viewMode === 'conservation_pending' ? 'Rimuovi da Da Conservare' : 'Invia a Conservazione'}
className={cn(
'p-1 rounded hover:bg-muted transition-all',
message.is_pending_conservation
? 'opacity-100'
: hovered
? 'opacity-100'
: 'opacity-0 pointer-events-none',
)}
>
{viewMode === 'conservation_pending' ? (
<ShieldX className="h-4 w-4 text-orange-500" />
) : (
<ShieldCheck
className={cn(
'h-4 w-4',
message.is_pending_conservation ? 'text-teal-600' : 'text-muted-foreground',
)}
/>
)}
</button>
)}
{/* Indicatore allegati */}
{message.has_attachments && (
<span className="text-xs text-muted-foreground"></span>
+7
View File
@@ -262,6 +262,10 @@ export interface MessageResponse {
archived_at: string | null
is_trashed: boolean
trashed_at: string | null
is_pending_conservation: boolean
pending_conservation_at: string | null
is_conserved: boolean
conserved_at: string | null
raw_eml_path: string | null
created_at: string
updated_at: string
@@ -342,6 +346,7 @@ export interface PermissionGrantRequest {
can_read?: boolean
can_send?: boolean
can_manage?: boolean
can_conserve?: boolean
}
export interface MailboxUserPermissionResponse {
@@ -352,6 +357,7 @@ export interface MailboxUserPermissionResponse {
can_read: boolean
can_send: boolean
can_manage: boolean
can_conserve: boolean
granted_at: string
}
@@ -362,6 +368,7 @@ export interface UserMailboxPermissionResponse {
can_read: boolean
can_send: boolean
can_manage: boolean
can_conserve: boolean
}
// ─── Virtual Box ──────────────────────────────────────────────────────────────