Fascicoli+Tassonomia+permessi

This commit is contained in:
2026-06-17 21:47:46 +02:00
parent e31676d22e
commit 3fd3c72f06
42 changed files with 4554 additions and 99 deletions
+9
View File
@@ -19,6 +19,10 @@ import { RoutingRulesPage } from '@/pages/RoutingRules/RoutingRulesPage'
import { ContactsPage } from '@/pages/Contacts/ContactsPage'
import { DeadlinesPage } from '@/pages/Deadlines/DeadlinesPage'
import { SignaturesPage } from '@/pages/Signatures/SignaturesPage'
import { FascicoliPage } from '@/pages/Fascicoli/FascicoliPage'
import { FascicoloDetailPage } from '@/pages/Fascicoli/FascicoloDetailPage'
import { TaxonomyPage } from '@/pages/Taxonomy/TaxonomyPage'
import { PermissionPresetsPage } from '@/pages/PermissionPresets/PermissionPresetsPage'
/**
* Routing principale dell'applicazione PEChub.
@@ -82,6 +86,7 @@ export default function App() {
<Route path="/mailboxes" element={<MailboxesPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/permissions" element={<PermissionsPage />} />
<Route path="/permission-presets" element={<PermissionPresetsPage />} />
<Route path="/virtual-boxes" element={<VirtualBoxesPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
@@ -103,6 +108,10 @@ export default function App() {
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/deadlines" element={<DeadlinesPage />} />
<Route path="/signatures" element={<SignaturesPage />} />
<Route path="/fascicoli" element={<FascicoliPage />} />
<Route path="/fascicoli/:id" element={<FascicoloDetailPage />} />
{/* Tassonomia di classificazione multi-livello (N2) */}
<Route path="/taxonomy" element={<TaxonomyPage />} />
{/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} />
+120
View File
@@ -0,0 +1,120 @@
import apiClient from './client'
// ─── Tipi ─────────────────────────────────────────────────────────────────────
export interface FascicoloResponse {
id: string
tenant_id: string
titolo: string
numero_pratica: string | null
stato: 'aperto' | 'chiuso' | 'archiviato'
categoria: string | null
responsabile_id: string | null
scadenza: string | null
note: string | null
created_by: string | null
created_at: string
updated_at: string
message_count: number
}
export interface FascicoloCreate {
titolo: string
numero_pratica?: string | null
stato?: 'aperto' | 'chiuso' | 'archiviato'
categoria?: string | null
responsabile_id?: string | null
scadenza?: string | null
note?: string | null
}
export interface FascicoloUpdate {
titolo?: string
numero_pratica?: string | null
stato?: 'aperto' | 'chiuso' | 'archiviato'
categoria?: string | null
responsabile_id?: string | null
scadenza?: string | null
note?: string | null
}
export interface FascicoloMessageItem {
id: string
subject: string | null
from_address: string | null
to_addresses: string[] | null
direction: 'inbound' | 'outbound'
pec_type: string
state: string
mailbox_id: string
received_at: string | null
sent_at: string | null
created_at: string
added_at: string
}
export interface MessageFascicoloSummary {
id: string
titolo: string
numero_pratica: string | null
stato: 'aperto' | 'chiuso' | 'archiviato'
categoria: string | null
}
// ─── Client API ───────────────────────────────────────────────────────────────
export const fascicoliApi = {
/** Lista fascicoli con filtri opzionali */
list: (params?: {
stato?: string
responsabile_id?: string
search?: string
}) =>
apiClient
.get<FascicoloResponse[]>('/fascicoli', { params })
.then((r) => r.data),
/** Dettaglio fascicolo */
get: (id: string) =>
apiClient.get<FascicoloResponse>(`/fascicoli/${id}`).then((r) => r.data),
/** Crea fascicolo */
create: (data: FascicoloCreate) =>
apiClient.post<FascicoloResponse>('/fascicoli', data).then((r) => r.data),
/** Modifica fascicolo */
update: (id: string, data: FascicoloUpdate) =>
apiClient
.patch<FascicoloResponse>(`/fascicoli/${id}`, data)
.then((r) => r.data),
/** Elimina fascicolo */
delete: (id: string) =>
apiClient.delete(`/fascicoli/${id}`).then((r) => r.data),
/** Messaggi del fascicolo */
getMessages: (id: string) =>
apiClient
.get<FascicoloMessageItem[]>(`/fascicoli/${id}/messages`)
.then((r) => r.data),
/** Aggiungi messaggi al fascicolo */
addMessages: (id: string, message_ids: string[]) =>
apiClient
.post<{ added: number }>(`/fascicoli/${id}/messages`, { message_ids })
.then((r) => r.data),
/** Rimuovi messaggi dal fascicolo */
removeMessages: (id: string, message_ids: string[]) =>
apiClient
.delete<{ removed: number }>(`/fascicoli/${id}/messages`, {
data: { message_ids },
})
.then((r) => r.data),
/** Fascicoli a cui appartiene un messaggio */
getMessageFascicoli: (messageId: string) =>
apiClient
.get<MessageFascicoloSummary[]>(`/messages/${messageId}/fascicoli`)
.then((r) => r.data),
}
+5
View File
@@ -2,6 +2,7 @@ import apiClient from './client'
import type {
LabelCreate,
LabelResponse,
LabelTreeResponse,
LabelUpdate,
MessageBulkLabelRequest,
MessageBulkLabelResponse,
@@ -16,6 +17,10 @@ export const labelsApi = {
list: () =>
apiClient.get<LabelResponse[]>('/labels').then((r) => r.data),
/** Restituisce la tassonomia come albero annidato (Ambito > Processo > Classificazione). */
getTree: () =>
apiClient.get<LabelTreeResponse[]>('/labels/tree').then((r) => r.data),
create: (data: LabelCreate) =>
apiClient.post<LabelResponse>('/labels', data).then((r) => r.data),
+18
View File
@@ -39,6 +39,18 @@ export interface MessageBulkUpdatePayload {
is_conserved?: boolean
}
export interface MessageUpdatePayload {
is_read?: boolean
is_starred?: boolean
is_archived?: boolean
is_trashed?: boolean
is_pending_conservation?: boolean
is_conserved?: boolean
/** Rischio e Riservatezza (N3) — stringa vuota per resettare a null */
risk_level?: string
confidentiality?: string
}
export interface MessageBulkUpdateResponse {
updated: number
items: MessageResponse[]
@@ -96,6 +108,12 @@ export const messagesApi = {
.patch<MessageResponse>(`/messages/${id}`, { is_pending_conservation: false })
.then((r) => r.data),
/** Aggiorna uno o piu' campi del messaggio (PATCH generico) — include risk_level/confidentiality (N3) */
update: (id: string, payload: MessageUpdatePayload) =>
apiClient
.patch<MessageResponse>(`/messages/${id}`, payload)
.then((r) => r.data),
/** Aggiorna in blocco is_starred e/o is_archived e/o is_trashed su più messaggi */
bulkUpdate: (payload: MessageBulkUpdatePayload) =>
apiClient
@@ -0,0 +1,38 @@
import apiClient from './client'
import type {
PermissionPresetCreate,
PermissionPresetResponse,
PermissionPresetUpdate,
} from '@/types/api.types'
export const permissionPresetsApi = {
/**
* Lista tutti i preset del tenant corrente.
*/
list: () =>
apiClient
.get<PermissionPresetResponse[]>('/permission-presets')
.then((r) => r.data),
/**
* Crea un nuovo preset.
*/
create: (data: PermissionPresetCreate) =>
apiClient
.post<PermissionPresetResponse>('/permission-presets', data)
.then((r) => r.data),
/**
* Aggiorna un preset esistente.
*/
update: (id: string, data: PermissionPresetUpdate) =>
apiClient
.put<PermissionPresetResponse>(`/permission-presets/${id}`, data)
.then((r) => r.data),
/**
* Elimina un preset.
*/
delete: (id: string) =>
apiClient.delete(`/permission-presets/${id}`),
}
+24 -2
View File
@@ -1,8 +1,30 @@
import apiClient from './client'
export type ConditionField = 'from_address' | 'to_address' | 'subject' | 'mailbox_id' | 'pec_type'
export type ConditionField =
| 'from_address'
| 'to_address'
| 'subject'
| 'mailbox_id'
| 'pec_type'
/** Tassonomia (N2): verifica se il messaggio ha gia' una specifica etichetta (UUID come valore) */
| 'has_label'
/** Rischio e Riservatezza (N3): verifica il livello gia' impostato sul messaggio */
| 'risk_level'
| 'confidentiality'
export type ConditionOperator = 'contains' | 'equals' | 'starts_with' | 'ends_with' | 'regex' | 'not_contains'
export type ActionType = 'apply_label' | 'assign_vbox' | 'mark_read' | 'mark_starred' | 'notify_webhook'
export type ActionType =
| 'apply_label'
| 'assign_vbox'
| 'mark_read'
| 'mark_starred'
| 'notify_webhook'
/** Tassonomia (N2): applica un nodo tassonomico (Ambito/Processo/Classificazione) */
| 'apply_taxonomy'
/** Rischio e Riservatezza (N3): imposta il livello di rischio o riservatezza */
| 'set_risk_level'
| 'set_confidentiality'
export interface RoutingRuleCondition {
id: string
+44 -16
View File
@@ -59,6 +59,9 @@ import {
BookUser,
Calendar,
PenLine,
FolderOpen,
Tags,
Sliders,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
@@ -414,6 +417,22 @@ export function Sidebar() {
<div>
<div className="border-t border-gray-700 mx-4 mb-3" />
<div className="px-2 space-y-0.5">
<NavLink
to="/fascicoli"
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 ? 'Fascicoli' : undefined}
>
<FolderOpen className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Fascicoli</span>}
</NavLink>
<NavLink
to="/deadlines"
className={({ isActive }) =>
@@ -515,22 +534,29 @@ export function Sidebar() {
{collapsed && <div className="border-t border-gray-700 mx-3 mb-2" />}
<div className="space-y-0.5 px-2">
<NavLink
to="/mailboxes"
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-cyan-700 text-white'
: 'text-cyan-300 hover:bg-cyan-900/40 hover:text-white',
collapsed && 'justify-center px-2',
)
}
title={collapsed ? 'Caselle PEC' : undefined}
>
<MailCheck className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>Caselle PEC</span>}
</NavLink>
{([
{ to: '/mailboxes', label: 'Caselle PEC', icon: MailCheck },
{ to: '/permissions', label: 'Permessi', icon: Shield },
{ to: '/permission-presets', label: 'Preset Permessi', icon: Sliders },
] 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-cyan-700 text-white'
: 'text-cyan-300 hover:bg-cyan-900/40 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>
)}
@@ -553,8 +579,10 @@ export function Sidebar() {
{ to: '/mailboxes', label: 'Caselle PEC', icon: MailCheck },
{ to: '/users', label: 'Utenti', icon: Users },
{ to: '/permissions', label: 'Permessi', icon: Shield },
{ to: '/permission-presets', label: 'Preset Permessi', icon: Sliders },
{ to: '/virtual-boxes', label: 'Virtual Box', icon: Filter },
{ to: '/routing-rules', label: 'Regole smistamento', icon: Settings2 },
{ to: '/taxonomy', label: 'Tassonomia', icon: Tags },
{ to: '/templates', label: 'Template messaggi', icon: FileText },
{ to: '/signatures', label: 'Firme automatiche', icon: PenLine },
{ to: '/notifications', label: 'Notifiche', icon: Bell },
@@ -0,0 +1,145 @@
/**
* Badge per il Livello di Rischio e la Riservatezza (Feature N3).
*
* RiskLevelBadge mostra il livello di rischio operativo
* ConfidentialityBadge mostra il grado di riservatezza
*/
import { ShieldAlert, Lock } from 'lucide-react'
// ─── Configurazione colori e label ───────────────────────────────────────────
const RISK_CONFIG: Record<string, { label: string; bgClass: string; textClass: string; borderClass: string }> = {
low: {
label: 'Basso',
bgClass: 'bg-green-50',
textClass: 'text-green-700',
borderClass: 'border-green-300',
},
medium: {
label: 'Medio',
bgClass: 'bg-yellow-50',
textClass: 'text-yellow-700',
borderClass: 'border-yellow-300',
},
high: {
label: 'Alto',
bgClass: 'bg-orange-50',
textClass: 'text-orange-700',
borderClass: 'border-orange-300',
},
critical: {
label: 'Critico',
bgClass: 'bg-red-50',
textClass: 'text-red-700',
borderClass: 'border-red-300',
},
}
const CONFIDENTIALITY_CONFIG: Record<string, { label: string; bgClass: string; textClass: string; borderClass: string }> = {
public: {
label: 'Pubblico',
bgClass: 'bg-gray-50',
textClass: 'text-gray-600',
borderClass: 'border-gray-300',
},
internal: {
label: 'Interno',
bgClass: 'bg-blue-50',
textClass: 'text-blue-700',
borderClass: 'border-blue-300',
},
confidential: {
label: 'Riservato',
bgClass: 'bg-purple-50',
textClass: 'text-purple-700',
borderClass: 'border-purple-300',
},
secret: {
label: 'Segreto',
bgClass: 'bg-red-100',
textClass: 'text-red-800',
borderClass: 'border-red-400',
},
}
// ─── Componenti ───────────────────────────────────────────────────────────────
interface RiskLevelBadgeProps {
level: string
/** Se true mostra solo il badge piccolo senza testo (per la lista messaggi) */
compact?: boolean
}
export function RiskLevelBadge({ level, compact = false }: RiskLevelBadgeProps) {
const config = RISK_CONFIG[level]
if (!config) return null
if (compact) {
return (
<span
title={`Rischio: ${config.label}`}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium border ${config.bgClass} ${config.textClass} ${config.borderClass}`}
>
<ShieldAlert className="h-3 w-3" />
{config.label}
</span>
)
}
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${config.bgClass} ${config.textClass} ${config.borderClass}`}
>
<ShieldAlert className="h-3.5 w-3.5" />
Rischio: {config.label}
</span>
)
}
interface ConfidentialityBadgeProps {
level: string
compact?: boolean
}
export function ConfidentialityBadge({ level, compact = false }: ConfidentialityBadgeProps) {
const config = CONFIDENTIALITY_CONFIG[level]
if (!config) return null
if (compact) {
return (
<span
title={`Riservatezza: ${config.label}`}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium border ${config.bgClass} ${config.textClass} ${config.borderClass}`}
>
<Lock className="h-3 w-3" />
{config.label}
</span>
)
}
return (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${config.bgClass} ${config.textClass} ${config.borderClass}`}
>
<Lock className="h-3.5 w-3.5" />
{config.label}
</span>
)
}
// ─── Costanti esportate per i form ────────────────────────────────────────────
export const RISK_LEVEL_OPTIONS = [
{ value: 'low', label: 'Basso' },
{ value: 'medium', label: 'Medio' },
{ value: 'high', label: 'Alto' },
{ value: 'critical', label: 'Critico' },
]
export const CONFIDENTIALITY_OPTIONS = [
{ value: 'public', label: 'Pubblico' },
{ value: 'internal', label: 'Interno' },
{ value: 'confidential', label: 'Riservato' },
{ value: 'secret', label: 'Segreto' },
]
@@ -0,0 +1,442 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
FolderOpen,
Plus,
Search,
X,
Pencil,
Trash2,
FolderCheck,
FolderArchive,
ChevronRight,
MessageSquare,
Calendar,
} from 'lucide-react'
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 { fascicoliApi, type FascicoloResponse, type FascicoloCreate, type FascicoloUpdate } from '@/api/fascicoli.api'
import { formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
import { useAuth } from '@/hooks/useAuth'
// ─── Badge stato ──────────────────────────────────────────────────────────────
function StatoBadge({ stato }: { stato: FascicoloResponse['stato'] }) {
const config: Record<string, { label: string; className: string; Icon: React.ComponentType<{ className?: string }> }> = {
aperto: {
label: 'Aperto',
className: 'bg-green-100 text-green-800 border border-green-200',
Icon: FolderOpen,
},
chiuso: {
label: 'Chiuso',
className: 'bg-gray-100 text-gray-700 border border-gray-200',
Icon: FolderCheck,
},
archiviato: {
label: 'Archiviato',
className: 'bg-amber-100 text-amber-800 border border-amber-200',
Icon: FolderArchive,
},
}
const { label, className, Icon } = config[stato] ?? config.aperto
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${className}`}>
<Icon className="h-3 w-3" />
{label}
</span>
)
}
// ─── Dialog crea / modifica fascicolo ─────────────────────────────────────────
interface FascicoloDialogProps {
fascicolo?: FascicoloResponse
onClose: () => void
}
function FascicoloDialog({ fascicolo, onClose }: FascicoloDialogProps) {
const queryClient = useQueryClient()
const isEdit = !!fascicolo
const [form, setForm] = useState<FascicoloCreate & { stato: 'aperto' | 'chiuso' | 'archiviato' }>({
titolo: fascicolo?.titolo ?? '',
numero_pratica: fascicolo?.numero_pratica ?? '',
stato: fascicolo?.stato ?? 'aperto',
categoria: fascicolo?.categoria ?? '',
scadenza: fascicolo?.scadenza ? fascicolo.scadenza.substring(0, 16) : '',
note: fascicolo?.note ?? '',
})
const createMutation = useMutation({
mutationFn: (data: FascicoloCreate) => fascicoliApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Fascicolo creato')
onClose()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: (data: FascicoloUpdate) => fascicoliApi.update(fascicolo!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Fascicolo aggiornato')
onClose()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const isPending = createMutation.isPending || updateMutation.isPending
const handleSubmit = () => {
if (!form.titolo.trim()) {
toast.error('Il titolo e obbligatorio')
return
}
const payload = {
titolo: form.titolo.trim(),
numero_pratica: form.numero_pratica?.trim() || null,
stato: form.stato,
categoria: form.categoria?.trim() || null,
scadenza: form.scadenza ? new Date(form.scadenza).toISOString() : null,
note: form.note?.trim() || null,
}
if (isEdit) {
updateMutation.mutate(payload)
} else {
createMutation.mutate(payload)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl w-full max-w-lg">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b">
<h3 className="text-lg font-semibold flex items-center gap-2">
<FolderOpen className="h-5 w-5 text-primary" />
{isEdit ? 'Modifica fascicolo' : 'Nuovo fascicolo'}
</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<div className="px-5 py-4 space-y-4">
<div className="space-y-1">
<Label>Titolo *</Label>
<Input
value={form.titolo}
onChange={(e) => setForm((f) => ({ ...f, titolo: e.target.value }))}
placeholder="Es. Procedura espropriativa via Roma 12"
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Numero pratica</Label>
<Input
value={form.numero_pratica ?? ''}
onChange={(e) => setForm((f) => ({ ...f, numero_pratica: e.target.value }))}
placeholder="Es. 2024/0042"
/>
</div>
<div className="space-y-1">
<Label>Stato</Label>
<select
value={form.stato}
onChange={(e) => setForm((f) => ({ ...f, stato: e.target.value as typeof form.stato }))}
className="w-full h-9 rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="aperto">Aperto</option>
<option value="chiuso">Chiuso</option>
<option value="archiviato">Archiviato</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Categoria</Label>
<Input
value={form.categoria ?? ''}
onChange={(e) => setForm((f) => ({ ...f, categoria: e.target.value }))}
placeholder="Es. Contenzioso, Contratti..."
/>
</div>
<div className="space-y-1">
<Label>Scadenza</Label>
<Input
type="datetime-local"
value={form.scadenza ?? ''}
onChange={(e) => setForm((f) => ({ ...f, scadenza: e.target.value }))}
/>
</div>
</div>
<div className="space-y-1">
<Label>Note</Label>
<textarea
value={form.note ?? ''}
onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))}
placeholder="Descrizione, riferimenti normativi..."
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
/>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 px-5 py-4 border-t">
<Button variant="outline" onClick={onClose}>Annulla</Button>
<Button onClick={handleSubmit} isLoading={isPending}>
{isEdit ? 'Salva modifiche' : 'Crea fascicolo'}
</Button>
</div>
</div>
</div>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function FascicoliPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { isAdmin } = useAuth()
const [search, setSearch] = useState('')
const [filterStato, setFilterStato] = useState('')
const [showDialog, setShowDialog] = useState(false)
const [editFascicolo, setEditFascicolo] = useState<FascicoloResponse | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<FascicoloResponse | null>(null)
const { data: fascicoli = [], isLoading } = useQuery({
queryKey: ['fascicoli', filterStato, search],
queryFn: () =>
fascicoliApi.list({
stato: filterStato || undefined,
search: search || undefined,
}),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => fascicoliApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Fascicolo eliminato')
setDeleteConfirm(null)
},
onError: (e) => toast.error(getErrorMessage(e)),
})
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>
<h1 className="text-xl font-semibold flex items-center gap-2">
<FolderOpen className="h-5 w-5 text-primary" />
Fascicoli
</h1>
<p className="text-sm text-muted-foreground">
{fascicoli.length} {fascicoli.length === 1 ? 'fascicolo' : 'fascicoli'}
</p>
</div>
<Button onClick={() => { setEditFascicolo(null); setShowDialog(true) }}>
<Plus className="h-4 w-4 mr-1" />
Nuovo fascicolo
</Button>
</div>
{/* Filtri */}
<div className="border-b bg-background px-6 py-3 flex items-center gap-3">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Cerca per titolo, numero pratica..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
value={filterStato}
onChange={(e) => setFilterStato(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Tutti gli stati</option>
<option value="aperto">Aperti</option>
<option value="chiuso">Chiusi</option>
<option value="archiviato">Archiviati</option>
</select>
{(search || filterStato) && (
<Button
variant="ghost"
size="sm"
onClick={() => { setSearch(''); setFilterStato('') }}
>
<X className="h-4 w-4 mr-1" />
Pulisci
</Button>
)}
</div>
{/* Contenuto */}
<div className="flex-1 overflow-y-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>
) : fascicoli.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<FolderOpen className="h-14 w-14 mx-auto mb-4 opacity-20" />
<p className="text-lg font-medium">Nessun fascicolo trovato</p>
<p className="text-sm mt-1">
{search || filterStato
? 'Prova a modificare i filtri di ricerca.'
: 'Crea il primo fascicolo per raggruppare le comunicazioni PEC.'}
</p>
{!search && !filterStato && (
<Button
className="mt-4"
onClick={() => { setEditFascicolo(null); setShowDialog(true) }}
>
<Plus className="h-4 w-4 mr-1" />
Crea fascicolo
</Button>
)}
</div>
) : (
<div className="max-w-4xl mx-auto space-y-3">
{fascicoli.map((f) => (
<div
key={f.id}
className="flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow cursor-pointer group"
onClick={() => navigate(`/fascicoli/${f.id}`)}
>
{/* Icona stato */}
<div className="flex-shrink-0">
{f.stato === 'aperto' && <FolderOpen className="h-6 w-6 text-green-600" />}
{f.stato === 'chiuso' && <FolderCheck className="h-6 w-6 text-gray-500" />}
{f.stato === 'archiviato' && <FolderArchive className="h-6 w-6 text-amber-600" />}
</div>
{/* Info principale */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-semibold truncate">{f.titolo}</p>
<StatoBadge stato={f.stato} />
{f.numero_pratica && (
<span className="text-xs text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded">
#{f.numero_pratica}
</span>
)}
{f.categoria && (
<span className="text-xs text-muted-foreground bg-blue-50 border border-blue-100 px-1.5 py-0.5 rounded">
{f.categoria}
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{f.message_count} {f.message_count === 1 ? 'messaggio' : 'messaggi'}
</span>
{f.scadenza && (
<span className="flex items-center gap-1 text-amber-600">
<Calendar className="h-3 w-3" />
Scade: {formatDate(f.scadenza)}
</span>
)}
<span>Aggiornato: {formatDate(f.updated_at)}</span>
</div>
{f.note && (
<p className="text-xs text-muted-foreground italic mt-0.5 truncate">{f.note}</p>
)}
</div>
{/* Azioni */}
<div className="flex items-center gap-1 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setEditFascicolo(f)
setShowDialog(true)
}}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Modifica"
>
<Pencil className="h-4 w-4" />
</button>
{isAdmin && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setDeleteConfirm(f)
}}
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="Elimina"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div>
))}
</div>
)}
</div>
{/* Dialog crea / modifica */}
{showDialog && (
<FascicoloDialog
fascicolo={editFascicolo ?? undefined}
onClose={() => { setShowDialog(false); setEditFascicolo(null) }}
/>
)}
{/* Dialog conferma eliminazione */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl p-6 w-full max-w-md space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2 text-destructive">
<Trash2 className="h-5 w-5" />
Elimina fascicolo
</h3>
<p className="text-sm text-muted-foreground">
Stai per eliminare il fascicolo{' '}
<strong>{deleteConfirm.titolo}</strong>.
I messaggi collegati non verranno eliminati.
Questa operazione non puo essere annullata.
</p>
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Annulla
</Button>
<Button
variant="destructive"
isLoading={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(deleteConfirm.id)}
>
Elimina
</Button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,547 @@
import { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
ArrowLeft,
FolderOpen,
FolderCheck,
FolderArchive,
MessageSquare,
Calendar,
Pencil,
Trash2,
Plus,
X,
Search,
Inbox,
Send,
ExternalLink,
AlertTriangle,
CheckCircle2,
} from 'lucide-react'
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 {
fascicoliApi,
type FascicoloResponse,
type FascicoloMessageItem,
type FascicoloUpdate,
} from '@/api/fascicoli.api'
import { messagesApi } from '@/api/messages.api'
import { formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
import { useAuth } from '@/hooks/useAuth'
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
// ─── Badge stato ──────────────────────────────────────────────────────────────
function StatoBadge({ stato }: { stato: FascicoloResponse['stato'] }) {
const config: Record<string, { label: string; className: string }> = {
aperto: { label: 'Aperto', className: 'bg-green-100 text-green-800 border border-green-200' },
chiuso: { label: 'Chiuso', className: 'bg-gray-100 text-gray-700 border border-gray-200' },
archiviato: { label: 'Archiviato', className: 'bg-amber-100 text-amber-800 border border-amber-200' },
}
const { label, className } = config[stato] ?? config.aperto
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${className}`}>
{label}
</span>
)
}
// ─── Dialog modifica fascicolo ────────────────────────────────────────────────
interface EditDialogProps {
fascicolo: FascicoloResponse
onClose: () => void
}
function EditDialog({ fascicolo, onClose }: EditDialogProps) {
const queryClient = useQueryClient()
const [form, setForm] = useState<FascicoloUpdate & { stato: 'aperto' | 'chiuso' | 'archiviato' }>({
titolo: fascicolo.titolo,
numero_pratica: fascicolo.numero_pratica ?? '',
stato: fascicolo.stato,
categoria: fascicolo.categoria ?? '',
scadenza: fascicolo.scadenza ? fascicolo.scadenza.substring(0, 16) : '',
note: fascicolo.note ?? '',
})
const updateMutation = useMutation({
mutationFn: (data: FascicoloUpdate) => fascicoliApi.update(fascicolo.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fascicolo', fascicolo.id] })
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Fascicolo aggiornato')
onClose()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const handleSubmit = () => {
if (!form.titolo?.trim()) { toast.error('Il titolo e obbligatorio'); return }
updateMutation.mutate({
titolo: form.titolo?.trim(),
numero_pratica: (form.numero_pratica as string)?.trim() || null,
stato: form.stato,
categoria: (form.categoria as string)?.trim() || null,
scadenza: form.scadenza ? new Date(form.scadenza as string).toISOString() : null,
note: (form.note as string)?.trim() || null,
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl w-full max-w-lg">
<div className="flex items-center justify-between px-5 py-4 border-b">
<h3 className="text-lg font-semibold">Modifica fascicolo</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-muted"><X className="h-5 w-5" /></button>
</div>
<div className="px-5 py-4 space-y-4">
<div className="space-y-1">
<Label>Titolo *</Label>
<Input value={form.titolo ?? ''} onChange={(e) => setForm((f) => ({ ...f, titolo: e.target.value }))} autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Numero pratica</Label>
<Input value={form.numero_pratica as string ?? ''} onChange={(e) => setForm((f) => ({ ...f, numero_pratica: e.target.value }))} placeholder="Es. 2024/0042" />
</div>
<div className="space-y-1">
<Label>Stato</Label>
<select value={form.stato} onChange={(e) => setForm((f) => ({ ...f, stato: e.target.value as typeof form.stato }))}
className="w-full h-9 rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
<option value="aperto">Aperto</option>
<option value="chiuso">Chiuso</option>
<option value="archiviato">Archiviato</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Categoria</Label>
<Input value={form.categoria as string ?? ''} onChange={(e) => setForm((f) => ({ ...f, categoria: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Scadenza</Label>
<Input type="datetime-local" value={form.scadenza as string ?? ''} onChange={(e) => setForm((f) => ({ ...f, scadenza: e.target.value }))} />
</div>
</div>
<div className="space-y-1">
<Label>Note</Label>
<textarea value={form.note as string ?? ''} onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))} rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none" />
</div>
</div>
<div className="flex justify-end gap-3 px-5 py-4 border-t">
<Button variant="outline" onClick={onClose}>Annulla</Button>
<Button onClick={handleSubmit} isLoading={updateMutation.isPending}>Salva modifiche</Button>
</div>
</div>
</div>
)
}
// ─── Modal selezione messaggi da aggiungere ───────────────────────────────────
interface AddMessagesModalProps {
fascicoloId: string
existingIds: Set<string>
onClose: () => void
}
function AddMessagesModal({ fascicoloId, existingIds, onClose }: AddMessagesModalProps) {
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const { data: messages = [] } = useQuery({
queryKey: ['messages-for-fascicolo', search],
queryFn: async () => {
// Carica messaggi recenti (non gia' nel fascicolo)
const params: Record<string, string | number> = { page: 1, page_size: 50, direction: 'all' }
if (search) params.search = search
const result = await messagesApi.list(params)
const items = (result as any).items ?? result
return items.filter((m: { id: string }) => !existingIds.has(m.id))
},
})
const addMutation = useMutation({
mutationFn: () => fascicoliApi.addMessages(fascicoloId, Array.from(selected)),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['fascicolo', fascicoloId] })
queryClient.invalidateQueries({ queryKey: ['fascicolo-messages', fascicoloId] })
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success(`${data.added} messaggi aggiunti al fascicolo`)
onClose()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="bg-background rounded-xl shadow-2xl w-full max-w-2xl flex flex-col max-h-[80vh]">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b flex-shrink-0">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Plus className="h-5 w-5 text-primary" />
Aggiungi messaggi al fascicolo
</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-muted"><X className="h-5 w-5" /></button>
</div>
{/* Ricerca */}
<div className="px-5 py-3 border-b flex-shrink-0">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input className="pl-8" placeholder="Cerca messaggi..." value={search} onChange={(e) => setSearch(e.target.value)} autoFocus />
</div>
</div>
{/* Lista messaggi */}
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-1.5">
{messages.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<CheckCircle2 className="h-10 w-10 mx-auto mb-3 opacity-20" />
<p className="text-sm">Nessun messaggio disponibile</p>
</div>
) : (
messages.map((msg: FascicoloMessageItem & { id: string; subject?: string; from_address?: string; to_addresses?: string[]; direction?: string; state?: string; received_at?: string; sent_at?: string }) => (
<label
key={msg.id}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selected.has(msg.id) ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
}`}
>
<input
type="checkbox"
checked={selected.has(msg.id)}
onChange={() => toggleSelect(msg.id)}
className="h-4 w-4 accent-primary flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{msg.subject || '(nessun oggetto)'}</p>
<p className="text-xs text-muted-foreground truncate">
{msg.direction === 'inbound' ? `Da: ${msg.from_address}` : `A: ${(msg.to_addresses ?? []).join(', ')}`}
{' · '}
{formatDate(msg.received_at || msg.sent_at || msg.created_at)}
</p>
</div>
<div className="flex-shrink-0">
{msg.direction === 'inbound'
? <Inbox className="h-4 w-4 text-blue-500" />
: <Send className="h-4 w-4 text-green-500" />}
</div>
</label>
))
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 py-4 border-t flex-shrink-0">
<p className="text-sm text-muted-foreground">
{selected.size > 0 ? `${selected.size} messaggi selezionati` : 'Seleziona i messaggi da aggiungere'}
</p>
<div className="flex gap-3">
<Button variant="outline" onClick={onClose}>Annulla</Button>
<Button
onClick={() => addMutation.mutate()}
disabled={selected.size === 0}
isLoading={addMutation.isPending}
>
Aggiungi {selected.size > 0 ? `(${selected.size})` : ''}
</Button>
</div>
</div>
</div>
</div>
)
}
// ─── Pagina dettaglio fascicolo ───────────────────────────────────────────────
export function FascicoloDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { isAdmin } = useAuth()
const [showEditDialog, setShowEditDialog] = useState(false)
const [showAddMessages, setShowAddMessages] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const { data: fascicolo, isLoading: loadingFasc } = useQuery({
queryKey: ['fascicolo', id],
queryFn: () => fascicoliApi.get(id!),
enabled: !!id,
})
const { data: messages = [], isLoading: loadingMsgs } = useQuery({
queryKey: ['fascicolo-messages', id],
queryFn: () => fascicoliApi.getMessages(id!),
enabled: !!id,
})
const removeMsgMutation = useMutation({
mutationFn: (msgId: string) => fascicoliApi.removeMessages(id!, [msgId]),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fascicolo-messages', id] })
queryClient.invalidateQueries({ queryKey: ['fascicolo', id] })
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Messaggio rimosso dal fascicolo')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteFascicoloMutation = useMutation({
mutationFn: () => fascicoliApi.delete(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Fascicolo eliminato')
navigate('/fascicoli')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
if (loadingFasc) {
return (
<div className="flex items-center justify-center h-64">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (!fascicolo) {
return (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<AlertTriangle className="h-10 w-10 text-muted-foreground opacity-40" />
<p className="text-muted-foreground">Fascicolo non trovato</p>
<Button variant="outline" onClick={() => navigate('/fascicoli')}>
<ArrowLeft className="h-4 w-4 mr-2" />
Torna ai fascicoli
</Button>
</div>
)
}
const existingIds = new Set(messages.map((m) => m.id))
const FolderIcon = fascicolo.stato === 'aperto'
? FolderOpen
: fascicolo.stato === 'chiuso'
? FolderCheck
: FolderArchive
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="border-b bg-background px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate('/fascicoli')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<span className="text-sm text-muted-foreground">Fascicoli</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowEditDialog(true)}>
<Pencil className="h-4 w-4 mr-1" />
Modifica
</Button>
{isAdmin && (
<Button variant="outline" size="sm" onClick={() => setDeleteConfirm(true)}
className="text-destructive border-destructive/30 hover:bg-destructive/10">
<Trash2 className="h-4 w-4 mr-1" />
Elimina
</Button>
)}
</div>
</div>
{/* Intestazione fascicolo */}
<div className="border-b bg-muted/30 px-6 py-5">
<div className="max-w-4xl mx-auto">
<div className="flex items-start gap-4">
<FolderIcon className={`h-10 w-10 flex-shrink-0 mt-0.5 ${
fascicolo.stato === 'aperto' ? 'text-green-600'
: fascicolo.stato === 'chiuso' ? 'text-gray-500'
: 'text-amber-600'
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-xl font-bold">{fascicolo.titolo}</h1>
<StatoBadge stato={fascicolo.stato} />
{fascicolo.numero_pratica && (
<span className="text-sm text-muted-foreground font-mono bg-background border px-2 py-0.5 rounded">
#{fascicolo.numero_pratica}
</span>
)}
</div>
<div className="flex items-center gap-6 mt-2 text-sm text-muted-foreground flex-wrap">
{fascicolo.categoria && (
<span className="flex items-center gap-1.5">
<span className="font-medium">Categoria:</span> {fascicolo.categoria}
</span>
)}
{fascicolo.scadenza && (
<span className="flex items-center gap-1.5 text-amber-700">
<Calendar className="h-4 w-4" />
<span className="font-medium">Scadenza:</span> {formatDate(fascicolo.scadenza)}
</span>
)}
<span className="flex items-center gap-1.5">
<MessageSquare className="h-4 w-4" />
{fascicolo.message_count} {fascicolo.message_count === 1 ? 'messaggio' : 'messaggi'}
</span>
<span>Aggiornato: {formatDate(fascicolo.updated_at)}</span>
</div>
{fascicolo.note && (
<p className="mt-2 text-sm text-muted-foreground italic">{fascicolo.note}</p>
)}
</div>
</div>
</div>
</div>
{/* Sezione messaggi */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-primary" />
Messaggi nel fascicolo ({messages.length})
</h2>
<Button size="sm" onClick={() => setShowAddMessages(true)}>
<Plus className="h-4 w-4 mr-1" />
Aggiungi messaggi
</Button>
</div>
{loadingMsgs ? (
<div className="flex justify-center py-10">
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : messages.length === 0 ? (
<div className="text-center py-14 text-muted-foreground border-2 border-dashed rounded-xl">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">Nessun messaggio nel fascicolo</p>
<p className="text-sm mt-1">Aggiungi messaggi PEC per costruire il fascicolo.</p>
<Button className="mt-4" size="sm" onClick={() => setShowAddMessages(true)}>
<Plus className="h-4 w-4 mr-1" />
Aggiungi messaggi
</Button>
</div>
) : (
<div className="space-y-2">
{messages.map((msg) => (
<div
key={msg.id}
className="flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow group"
>
{/* Icona direzione */}
<div className="flex-shrink-0">
{msg.direction === 'inbound'
? <Inbox className="h-5 w-5 text-blue-500" />
: <Send className="h-5 w-5 text-green-500" />}
</div>
{/* Info messaggio */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{msg.subject || '(nessun oggetto)'}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{msg.direction === 'inbound'
? `Da: ${msg.from_address}`
: `A: ${(msg.to_addresses ?? []).join(', ')}`}
{' · '}
{formatDate(msg.received_at || msg.sent_at || msg.created_at)}
</p>
<p className="text-xs text-muted-foreground/70">
Aggiunto: {formatDate(msg.added_at)}
</p>
</div>
{/* Badge stato */}
<div className="flex-shrink-0">
<PecStateBadge state={msg.state} />
</div>
{/* Azioni */}
<div className="flex items-center gap-1 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => navigate(`/messages/${msg.id}`)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-primary"
title="Apri messaggio"
>
<ExternalLink className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => removeMsgMutation.mutate(msg.id)}
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="Rimuovi dal fascicolo"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Modal modifica */}
{showEditDialog && (
<EditDialog fascicolo={fascicolo} onClose={() => setShowEditDialog(false)} />
)}
{/* Modal aggiungi messaggi */}
{showAddMessages && (
<AddMessagesModal
fascicoloId={id!}
existingIds={existingIds}
onClose={() => setShowAddMessages(false)}
/>
)}
{/* Conferma eliminazione */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl p-6 w-full max-w-md space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2 text-destructive">
<Trash2 className="h-5 w-5" />
Elimina fascicolo
</h3>
<p className="text-sm text-muted-foreground">
Stai per eliminare <strong>{fascicolo.titolo}</strong>.
I messaggi collegati non verranno eliminati.
Questa operazione non puo essere annullata.
</p>
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(false)}>Annulla</Button>
<Button
variant="destructive"
isLoading={deleteFascicoloMutation.isPending}
onClick={() => deleteFascicoloMutation.mutate()}
>
Elimina
</Button>
</div>
</div>
</div>
)}
</div>
)
}
+9
View File
@@ -52,6 +52,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 { RiskLevelBadge, ConfidentialityBadge } from '@/components/RiskBadge/RiskBadge'
import { useAuth } from '@/hooks/useAuth'
import { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api'
@@ -1093,6 +1094,14 @@ function MessageRow({
</div>
)}
{/* Badge Rischio e Riservatezza (Feature N3) */}
{(message.risk_level || message.confidentiality) && (
<div className="flex items-center gap-1.5 mt-1" onClick={(e) => e.stopPropagation()}>
{message.risk_level && <RiskLevelBadge level={message.risk_level} compact />}
{message.confidentiality && <ConfidentialityBadge level={message.confidentiality} compact />}
</div>
)}
{message.body_text && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
{truncate(message.body_text, 120)}
@@ -14,6 +14,7 @@ import {
Mail,
Send,
Tag,
Tags,
Trash2,
RotateCcw,
MailX,
@@ -24,6 +25,8 @@ import {
MessageSquare,
X,
ChevronDown,
FolderOpen,
Plus,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
@@ -33,9 +36,12 @@ import { PecStateBadge, PecTypeBadge } from '@/components/PecBadge/PecBadge'
import { ReceiptTree } from '@/components/ReceiptTree/ReceiptTree'
import { TagBadge } from '@/components/TagManager/TagBadge'
import { TagSelector } from '@/components/TagManager/TagSelector'
import { RiskLevelBadge, ConfidentialityBadge, RISK_LEVEL_OPTIONS, CONFIDENTIALITY_OPTIONS } from '@/components/RiskBadge/RiskBadge'
import { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api'
import { deadlinesApi } from '@/api/deadlines.api'
import { fascicoliApi, type FascicoloResponse } from '@/api/fascicoli.api'
import type { LabelResponse, MessageResponse } from '@/types/api.types'
import { formatDate, formatBytes } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
import apiClient from '@/api/client'
@@ -158,6 +164,426 @@ function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType
)
}
// ─── Widget Fascicoli nel dettaglio messaggio (N1) ────────────────────────────
function FascicoliWidget({ messageId, navigate }: { messageId: string; navigate: (to: string) => void }) {
const queryClient = useQueryClient()
const [showAddModal, setShowAddModal] = useState(false)
const { data: fascicoli = [] } = useQuery({
queryKey: ['message-fascicoli', messageId],
queryFn: () => fascicoliApi.getMessageFascicoli(messageId),
})
// Lista di tutti i fascicoli del tenant per il dropdown di aggiunta
const { data: allFascicoli = [] } = useQuery({
queryKey: ['fascicoli'],
queryFn: () => fascicoliApi.list(),
enabled: showAddModal,
})
const addMutation = useMutation({
mutationFn: (fascicoloId: string) =>
fascicoliApi.addMessages(fascicoloId, [messageId]),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['message-fascicoli', messageId] })
queryClient.invalidateQueries({ queryKey: ['fascicoli'] })
toast.success('Messaggio aggiunto al fascicolo')
setShowAddModal(false)
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const alreadyInIds = new Set(fascicoli.map((f) => f.id))
const available = (allFascicoli as FascicoloResponse[]).filter((f) => !alreadyInIds.has(f.id) && f.stato !== 'archiviato')
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<FolderOpen className="h-4 w-4" />
Fascicoli ({fascicoli.length})
</h3>
<div className="rounded-lg border bg-background p-3">
{fascicoli.length === 0 ? (
<p className="text-sm text-muted-foreground">Questo messaggio non appartiene a nessun fascicolo.</p>
) : (
<div className="flex flex-wrap gap-2 mb-2">
{fascicoli.map((f) => (
<button
key={f.id}
type="button"
onClick={() => navigate(`/fascicoli/${f.id}`)}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm bg-primary/10 text-primary border border-primary/20 hover:bg-primary/20 transition-colors"
>
<FolderOpen className="h-3.5 w-3.5" />
{f.titolo}
{f.numero_pratica && (
<span className="text-xs opacity-70">#{f.numero_pratica}</span>
)}
</button>
))}
</div>
)}
<button
type="button"
onClick={() => setShowAddModal(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs text-muted-foreground border border-dashed border-muted-foreground/40 hover:border-primary hover:text-primary transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Aggiungi a fascicolo
</button>
</div>
{/* Modal selezione fascicolo */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between px-5 py-4 border-b">
<h3 className="text-base font-semibold flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-primary" />
Aggiungi a fascicolo
</h3>
<button onClick={() => setShowAddModal(false)} className="p-1 rounded hover:bg-muted">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-5 py-3 max-h-72 overflow-y-auto space-y-1">
{available.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
<FolderOpen className="h-8 w-8 mx-auto mb-2 opacity-20" />
<p className="text-sm">
{allFascicoli.length === 0
? 'Nessun fascicolo disponibile. Creane uno dalla pagina Fascicoli.'
: 'Il messaggio e gia\' in tutti i fascicoli aperti.'}
</p>
</div>
) : (
available.map((f) => (
<button
key={f.id}
type="button"
onClick={() => addMutation.mutate(f.id)}
disabled={addMutation.isPending}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted transition-colors text-left"
>
<FolderOpen className="h-4 w-4 text-green-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{f.titolo}</p>
{f.numero_pratica && (
<p className="text-xs text-muted-foreground">#{f.numero_pratica}</p>
)}
</div>
</button>
))
)}
</div>
<div className="flex justify-between items-center px-5 py-3 border-t">
<button
type="button"
onClick={() => navigate('/fascicoli')}
className="text-xs text-primary hover:underline"
>
Gestisci fascicoli
</button>
<Button variant="outline" size="sm" onClick={() => setShowAddModal(false)}>
Chiudi
</Button>
</div>
</div>
</div>
)}
</div>
)
}
// ─── Widget Tassonomia nel dettaglio messaggio (N2) ───────────────────────────
function TaxonomyWidget({ messageId, messageLabels }: { messageId: string; messageLabels: LabelResponse[] }) {
const queryClient = useQueryClient()
const [showAddModal, setShowAddModal] = useState(false)
const [selectedLabelId, setSelectedLabelId] = useState('')
// Lista flat di tutte le label del tenant (per costruire i percorsi)
const { data: allLabels = [] } = useQuery({
queryKey: ['labels'],
queryFn: () => labelsApi.list(),
})
// Le label tassonomiche del messaggio sono quelle con parent_id != null
const taxonomyLabels = messageLabels.filter((l) => l.parent_id !== null)
// Costruisce il percorso completo di una label: "Ambito > Processo > Classificazione"
function buildPath(labelId: string): string {
const map = new Map<string, LabelResponse>(allLabels.map((l: LabelResponse) => [l.id, l]))
const parts: string[] = []
let current: LabelResponse | undefined = map.get(labelId)
while (current) {
parts.unshift(current.name)
const parentId = current.parent_id
if (parentId) {
current = map.get(parentId)
} else {
break
}
}
return parts.join(' > ')
}
// Label disponibili per l'aggiunta (non ancora assegnate)
const alreadyAssignedIds = new Set(messageLabels.map((l) => l.id))
const availableForAdd = allLabels.filter((l: LabelResponse) => !alreadyAssignedIds.has(l.id))
const addMutation = useMutation({
mutationFn: (labelId: string) =>
labelsApi.addMessageLabels(messageId, { label_ids: [labelId] }),
onSuccess: (updatedLabels: LabelResponse[]) => {
queryClient.setQueryData(['message', messageId], (old: any) => {
if (!old) return old
return { ...old, labels: updatedLabels }
})
queryClient.invalidateQueries({ queryKey: ['messages'] })
setShowAddModal(false)
setSelectedLabelId('')
toast.success('Classificazione aggiunta')
},
onError: (e: unknown) => toast.error(getErrorMessage(e)),
})
const removeMutation = useMutation({
mutationFn: (labelId: string) =>
labelsApi.removeMessageLabels(messageId, { label_ids: [labelId] }),
onSuccess: (updatedLabels: LabelResponse[]) => {
queryClient.setQueryData(['message', messageId], (old: any) => {
if (!old) return old
return { ...old, labels: updatedLabels }
})
queryClient.invalidateQueries({ queryKey: ['messages'] })
toast.success('Classificazione rimossa')
},
onError: (e: unknown) => toast.error(getErrorMessage(e)),
})
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Tags className="h-4 w-4" />
Classificazione tassonomica ({taxonomyLabels.length})
</h3>
<div className="rounded-lg border bg-background p-3">
{taxonomyLabels.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nessuna classificazione tassonomica assegnata a questo messaggio.
</p>
) : (
<div className="flex flex-wrap gap-2 mb-2">
{taxonomyLabels.map((lbl) => {
const path = buildPath(lbl.id)
return (
<span
key={lbl.id}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm border bg-background"
style={
lbl.color
? {
backgroundColor: lbl.color + '20',
borderColor: lbl.color + '60',
color: lbl.color,
}
: {}
}
>
{lbl.color && (
<span
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: lbl.color }}
/>
)}
<span className="font-medium">{path || lbl.name}</span>
<button
type="button"
onClick={() => removeMutation.mutate(lbl.id)}
disabled={removeMutation.isPending}
className="ml-1 opacity-50 hover:opacity-100 transition-opacity"
title="Rimuovi classificazione"
>
<X className="h-3 w-3" />
</button>
</span>
)
})}
</div>
)}
<button
type="button"
onClick={() => { setSelectedLabelId(''); setShowAddModal(true) }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs text-muted-foreground border border-dashed border-muted-foreground/40 hover:border-primary hover:text-primary transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Aggiungi classificazione
</button>
</div>
{/* Modal selezione classificazione */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between px-5 py-4 border-b">
<h3 className="text-base font-semibold flex items-center gap-2">
<Tags className="h-4 w-4 text-primary" />
Aggiungi classificazione tassonomica
</h3>
<button onClick={() => setShowAddModal(false)} className="p-1 rounded hover:bg-muted">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-5 py-4 space-y-3">
<p className="text-sm text-muted-foreground">
Seleziona un nodo della tassonomia da assegnare a questo messaggio.
Il percorso mostra: Ambito &gt; Processo &gt; Classificazione.
</p>
<select
className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm"
value={selectedLabelId}
onChange={(e) => setSelectedLabelId(e.target.value)}
>
<option value="">-- Seleziona classificazione --</option>
{availableForAdd.map((l: LabelResponse) => (
<option key={l.id} value={l.id}>
{buildPath(l.id) || l.name}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 px-5 py-3 border-t">
<Button variant="outline" size="sm" onClick={() => setShowAddModal(false)}>
Annulla
</Button>
<Button
size="sm"
disabled={!selectedLabelId}
isLoading={addMutation.isPending}
onClick={() => selectedLabelId && addMutation.mutate(selectedLabelId)}
>
Assegna
</Button>
</div>
</div>
</div>
)}
</div>
)
}
// ─── Widget Rischio e Riservatezza (N3) ──────────────────────────────────────
function RiskConfidentialityWidget({
message,
onUpdate,
}: {
message: MessageResponse
onUpdate: (updated: MessageResponse) => void
}) {
const [riskLevel, setRiskLevel] = useState<string>(message.risk_level ?? '')
const [confidentiality, setConfidentiality] = useState<string>(message.confidentiality ?? '')
const [isSaving, setIsSaving] = useState(false)
// Sincronizza stato locale quando il messaggio cambia
useEffect(() => {
setRiskLevel(message.risk_level ?? '')
setConfidentiality(message.confidentiality ?? '')
}, [message.risk_level, message.confidentiality])
const hasCurrent = message.risk_level || message.confidentiality
const hasChanges =
riskLevel !== (message.risk_level ?? '') ||
confidentiality !== (message.confidentiality ?? '')
const handleSave = async () => {
setIsSaving(true)
try {
const updated = await messagesApi.update(message.id, {
risk_level: riskLevel || '',
confidentiality: confidentiality || '',
})
onUpdate(updated)
toast.success('Rischio e riservatezza salvati')
} catch (e) {
toast.error(getErrorMessage(e))
} finally {
setIsSaving(false)
}
}
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<span className="inline-block h-4 w-4 text-orange-500">
{/* icona shield */}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
</span>
Rischio e Riservatezza
</h3>
<div className="rounded-lg border bg-background p-4 space-y-4">
{/* Badge correnti */}
{hasCurrent && (
<div className="flex items-center gap-2 flex-wrap">
{message.risk_level && (
<RiskLevelBadge level={message.risk_level} />
)}
{message.confidentiality && (
<ConfidentialityBadge level={message.confidentiality} />
)}
</div>
)}
{/* Form di modifica */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Livello di rischio</label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm"
value={riskLevel}
onChange={(e) => setRiskLevel(e.target.value)}
>
<option value="">-- Nessun rischio --</option>
{RISK_LEVEL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Riservatezza</label>
<select
className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm"
value={confidentiality}
onChange={(e) => setConfidentiality(e.target.value)}
>
<option value="">-- Nessuna riservatezza --</option>
{CONFIDENTIALITY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
{hasChanges && (
<div className="flex justify-end">
<Button
size="sm"
isLoading={isSaving}
onClick={handleSave}
>
Salva
</Button>
</div>
)}
</div>
</div>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function MessageDetailPage() {
@@ -924,6 +1350,18 @@ export function MessageDetailPage() {
<ThreadSection messageId={message.id} currentId={message.id} navigate={navigate} />
)}
{/* Fascicoli (N1) */}
<FascicoliWidget messageId={message.id} navigate={navigate} />
{/* Classificazione Tassonomica (N2) */}
<TaxonomyWidget messageId={message.id} messageLabels={message.labels || []} />
{/* Rischio e Riservatezza (N3) */}
<RiskConfidentialityWidget message={message} onUpdate={(updated) => {
queryClient.setQueryData(['message', id], updated)
queryClient.invalidateQueries({ queryKey: ['messages'] })
}} />
{/* Messaggio originale per ricevute */}
{message.direction === 'inbound' && message.pec_type !== 'posta_certificata' && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
@@ -0,0 +1,344 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Sliders, Plus, Pencil, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label'
import { permissionPresetsApi } from '@/api/permission_presets.api'
import { getErrorMessage } from '@/api/client'
import { cn } from '@/lib/utils'
import type { PermissionPresetResponse } from '@/types/api.types'
// ─── Flag visualizzazione permesso ───────────────────────────────────────────
interface PermissionBadgeProps {
active: boolean
label: string
}
function PermissionBadge({ active, label }: PermissionBadgeProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
active
? 'bg-primary/10 text-primary border border-primary/20'
: 'bg-muted text-muted-foreground border border-border',
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', active ? 'bg-primary' : 'bg-muted-foreground/40')} />
{label}
</span>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function PermissionPresetsPage() {
const queryClient = useQueryClient()
const [showDialog, setShowDialog] = useState(false)
const [editingPreset, setEditingPreset] = useState<PermissionPresetResponse | null>(null)
const { data: presets = [], isLoading } = useQuery({
queryKey: ['permission-presets'],
queryFn: () => permissionPresetsApi.list(),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => permissionPresetsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['permission-presets'] })
toast.success('Preset eliminato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const handleEdit = (preset: PermissionPresetResponse) => {
setEditingPreset(preset)
setShowDialog(true)
}
const handleNew = () => {
setEditingPreset(null)
setShowDialog(true)
}
const handleDelete = (preset: PermissionPresetResponse) => {
if (!confirm(`Eliminare il preset "${preset.name}"? L'operazione non e' reversibile.`)) return
deleteMutation.mutate(preset.id)
}
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">
<Sliders className="h-5 w-5 text-primary" />
<div>
<h1 className="text-xl font-semibold">Preset Permessi</h1>
<p className="text-sm text-muted-foreground">
Definisci template di permessi riutilizzabili (sottoruoli) per gli operatori
</p>
</div>
</div>
<Button onClick={handleNew}>
<Plus className="h-4 w-4 mr-2" />
Nuovo preset
</Button>
</div>
{/* Contenuto */}
<div className="flex-1 overflow-y-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>
) : presets.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Sliders className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="font-medium mb-1">Nessun preset definito</p>
<p className="text-sm text-muted-foreground mb-4">
Crea dei preset per applicare rapidamente combinazioni di permessi agli operatori.
</p>
<Button onClick={handleNew}>
<Plus className="h-4 w-4 mr-2" />
Crea primo preset
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{presets.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
onEdit={() => handleEdit(preset)}
onDelete={() => handleDelete(preset)}
isDeleting={deleteMutation.isPending}
/>
))}
</div>
)}
</div>
{/* Dialog crea/modifica */}
{showDialog && (
<PresetDialog
preset={editingPreset}
onClose={() => setShowDialog(false)}
onSaved={() => {
queryClient.invalidateQueries({ queryKey: ['permission-presets'] })
setShowDialog(false)
}}
/>
)}
</div>
)
}
// ─── Card preset ──────────────────────────────────────────────────────────────
interface PresetCardProps {
preset: PermissionPresetResponse
onEdit: () => void
onDelete: () => void
isDeleting: boolean
}
function PresetCard({ preset, onEdit, onDelete, isDeleting }: PresetCardProps) {
return (
<div className="rounded-lg border bg-card p-4 flex flex-col gap-3 hover:shadow-sm transition-shadow">
{/* Titolo e descrizione */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-semibold truncate">{preset.name}</h3>
{preset.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{preset.description}
</p>
)}
</div>
</div>
{/* Permessi */}
<div className="flex flex-wrap gap-1.5">
<PermissionBadge active={preset.can_read} label="Lettura" />
<PermissionBadge active={preset.can_send} label="Invio" />
<PermissionBadge active={preset.can_manage} label="Gestione" />
<PermissionBadge active={preset.can_conserve} label="Conservazione" />
</div>
{/* Azioni */}
<div className="flex justify-end gap-2 pt-1 border-t">
<Button
variant="ghost"
size="sm"
onClick={onEdit}
className="h-7 text-xs"
>
<Pencil className="h-3 w-3 mr-1" />
Modifica
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
disabled={isDeleting}
className="h-7 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3 w-3 mr-1" />
Elimina
</Button>
</div>
</div>
)
}
// ─── Dialog crea/modifica preset ─────────────────────────────────────────────
interface PresetDialogProps {
preset: PermissionPresetResponse | null
onClose: () => void
onSaved: () => void
}
function PresetDialog({ preset, onClose, onSaved }: PresetDialogProps) {
const isEdit = preset !== null
const [name, setName] = useState(preset?.name ?? '')
const [description, setDescription] = useState(preset?.description ?? '')
const [canRead, setCanRead] = useState(preset?.can_read ?? true)
const [canSend, setCanSend] = useState(preset?.can_send ?? false)
const [canManage, setCanManage] = useState(preset?.can_manage ?? false)
const [canConserve, setCanConserve] = useState(preset?.can_conserve ?? false)
const createMutation = useMutation({
mutationFn: () =>
permissionPresetsApi.create({
name: name.trim(),
description: description.trim() || null,
can_read: canRead,
can_send: canSend,
can_manage: canManage,
can_conserve: canConserve,
}),
onSuccess: () => {
toast.success('Preset creato')
onSaved()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: () =>
permissionPresetsApi.update(preset!.id, {
name: name.trim(),
description: description.trim() || null,
can_read: canRead,
can_send: canSend,
can_manage: canManage,
can_conserve: canConserve,
}),
onSuccess: () => {
toast.success('Preset aggiornato')
onSaved()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const isPending = createMutation.isPending || updateMutation.isPending
const handleSave = () => {
if (!name.trim()) {
toast.error('Il nome e\' obbligatorio')
return
}
if (isEdit) {
updateMutation.mutate()
} else {
createMutation.mutate()
}
}
const permFlags = [
{ key: 'can_read', label: 'Lettura messaggi', desc: 'Accesso in lettura alle PEC della casella', state: canRead, setState: setCanRead },
{ key: 'can_send', label: 'Invio PEC', desc: 'Possibilita\' di inviare messaggi dalla casella', state: canSend, setState: setCanSend },
{ key: 'can_manage', label: 'Gestione casella', desc: 'Modifica configurazione IMAP/SMTP della casella', state: canManage, setState: setCanManage },
{ key: 'can_conserve', label: 'Conservazione documenti', desc: 'Spostamento messaggi nella cartella di conservazione', state: canConserve, setState: setCanConserve },
]
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Modifica preset' : 'Nuovo preset permessi'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-2">
{/* Nome */}
<div className="space-y-1.5">
<Label htmlFor="preset-name">Nome <span className="text-destructive">*</span></Label>
<input
id="preset-name"
type="text"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Es. Operatore Archivio, Operatore Invio..."
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
/>
</div>
{/* Descrizione */}
<div className="space-y-1.5">
<Label htmlFor="preset-desc">Descrizione</Label>
<textarea
id="preset-desc"
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm resize-none"
rows={2}
placeholder="Descrizione opzionale del preset..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{/* Permessi */}
<div className="space-y-2">
<Label>Permessi inclusi nel preset</Label>
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
{permFlags.map(({ key, label, desc, state, setState }) => (
<label key={key} className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={state}
onChange={(e) => setState(e.target.checked)}
className="h-4 w-4 mt-0.5 rounded border-input flex-shrink-0"
/>
<div>
<p className="text-sm font-medium leading-tight">{label}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isPending}>
Annulla
</Button>
<Button onClick={handleSave} isLoading={isPending} disabled={!name.trim()}>
{isEdit ? 'Salva modifiche' : 'Crea preset'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -12,11 +12,15 @@ import {
} from '@/components/ui/Dialog'
import { Label } from '@/components/ui/Label'
import { permissionsApi } from '@/api/permissions.api'
import { permissionPresetsApi } from '@/api/permission_presets.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { usersApi } from '@/api/users.api'
import { getErrorMessage } from '@/api/client'
import { cn, ROLE_LABELS } from '@/lib/utils'
import type { MailboxUserPermissionResponse } from '@/types/api.types'
import type {
MailboxUserPermissionResponse,
PermissionPresetResponse,
} from '@/types/api.types'
export function PermissionsPage() {
const queryClient = useQueryClient()
@@ -139,9 +143,10 @@ export function PermissionsPage() {
<tr className="bg-muted/50 border-b">
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Utente</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Ruolo</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Lettura</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Invio</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Gestione</th>
<th className="text-center px-3 py-3 font-medium text-muted-foreground">Lettura</th>
<th className="text-center px-3 py-3 font-medium text-muted-foreground">Invio</th>
<th className="text-center px-3 py-3 font-medium text-muted-foreground">Gestione</th>
<th className="text-center px-3 py-3 font-medium text-muted-foreground">Conserv.</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Azioni</th>
</tr>
</thead>
@@ -196,17 +201,22 @@ interface PermissionRowProps {
function PermissionRow({ perm, mailboxId, onRevoke }: PermissionRowProps) {
const updateMutation = useMutation({
mutationFn: (data: { can_read: boolean; can_send: boolean; can_manage: boolean }) =>
permissionsApi.grant(mailboxId, perm.user_id, data),
mutationFn: (data: {
can_read: boolean
can_send: boolean
can_manage: boolean
can_conserve: boolean
}) => permissionsApi.grant(mailboxId, perm.user_id, data),
onSuccess: () => toast.success('Permesso aggiornato'),
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggle = (field: 'can_read' | 'can_send' | 'can_manage') => {
const toggle = (field: 'can_read' | 'can_send' | 'can_manage' | 'can_conserve') => {
updateMutation.mutate({
can_read: field === 'can_read' ? !perm.can_read : perm.can_read,
can_send: field === 'can_send' ? !perm.can_send : perm.can_send,
can_manage: field === 'can_manage' ? !perm.can_manage : perm.can_manage,
can_conserve: field === 'can_conserve' ? !perm.can_conserve : perm.can_conserve,
})
}
@@ -214,19 +224,20 @@ function PermissionRow({ perm, mailboxId, onRevoke }: PermissionRowProps) {
<tr className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<div>
<p className="font-medium">{perm.full_name}</p>
<p className="text-xs text-muted-foreground">{perm.email}</p>
<p className="font-medium">{perm.user_full_name}</p>
<p className="text-xs text-muted-foreground">{perm.user_email}</p>
</div>
</td>
<td className="px-4 py-3">
<span className="text-xs bg-muted px-2 py-0.5 rounded">
{ROLE_LABELS[perm.role] || perm.role}
{ROLE_LABELS[perm.user_role] || perm.user_role}
</span>
</td>
{(['can_read', 'can_send', 'can_manage'] as const).map((field) => (
<td key={field} className="px-4 py-3 text-center">
{(['can_read', 'can_send', 'can_manage', 'can_conserve'] as const).map((field) => (
<td key={field} className="px-3 py-3 text-center">
<button
onClick={() => toggle(field)}
disabled={updateMutation.isPending}
className={cn(
'h-5 w-5 rounded border-2 inline-flex items-center justify-center transition-colors',
perm[field]
@@ -234,7 +245,7 @@ function PermissionRow({ perm, mailboxId, onRevoke }: PermissionRowProps) {
: 'border-muted-foreground/40 bg-background hover:border-primary',
)}
>
{perm[field] && <span className="text-xs"></span>}
{perm[field] && <span className="text-xs"></span>}
</button>
</td>
))}
@@ -263,21 +274,29 @@ interface GrantPermissionDialogProps {
function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionDialogProps) {
const [selectedUserId, setSelectedUserId] = useState('')
const [selectedPresetId, setSelectedPresetId] = useState('')
const [canRead, setCanRead] = useState(true)
const [canSend, setCanSend] = useState(false)
const [canManage, setCanManage] = useState(false)
const [canConserve, setCanConserve] = useState(false)
const { data: usersData } = useQuery({
queryKey: ['users'],
queryFn: () => usersApi.list(1, 100),
})
const { data: presets = [] } = useQuery({
queryKey: ['permission-presets'],
queryFn: () => permissionPresetsApi.list(),
})
const grantMutation = useMutation({
mutationFn: () =>
permissionsApi.grant(mailboxId, selectedUserId, {
can_read: canRead,
can_send: canSend,
can_manage: canManage,
can_conserve: canConserve,
}),
onSuccess: () => {
toast.success('Permesso assegnato')
@@ -288,6 +307,23 @@ function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionD
const users = usersData?.items?.filter((u) => u.is_active) || []
const applyPreset = (preset: PermissionPresetResponse) => {
setCanRead(preset.can_read)
setCanSend(preset.can_send)
setCanManage(preset.can_manage)
setCanConserve(preset.can_conserve)
setSelectedPresetId(preset.id)
}
const handlePresetChange = (presetId: string) => {
if (!presetId) {
setSelectedPresetId('')
return
}
const preset = presets.find((p) => p.id === presetId)
if (preset) applyPreset(preset)
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
@@ -295,6 +331,7 @@ function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionD
<DialogTitle>Assegna permesso casella</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-4">
{/* Utente */}
<div className="space-y-2">
<Label>Utente</Label>
<select
@@ -311,19 +348,47 @@ function GrantPermissionDialog({ mailboxId, onClose, onSaved }: GrantPermissionD
</select>
</div>
{/* Preset (opzionale) */}
{presets.length > 0 && (
<div className="space-y-2">
<Label>Applica preset (opzionale)</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={selectedPresetId}
onChange={(e) => handlePresetChange(e.target.value)}
>
<option value="">Nessun preset configura manualmente</option>
{presets.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.description ? ` ${p.description}` : ''}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
Il preset pre-compila i permessi. Puoi modificarli liberamente prima di confermare.
</p>
</div>
)}
{/* Permessi */}
<div className="space-y-2">
<Label>Permessi</Label>
<div className="space-y-2">
<div className="space-y-2 rounded-md border p-3 bg-muted/30">
{[
{ key: 'can_read', label: 'Lettura messaggi', state: canRead, setState: setCanRead },
{ key: 'can_send', label: 'Invio PEC', state: canSend, setState: setCanSend },
{ key: 'can_manage', label: 'Gestione casella', state: canManage, setState: setCanManage },
{ key: 'can_conserve', label: 'Conservazione documenti', state: canConserve, setState: setCanConserve },
].map(({ key, label, state, setState }) => (
<label key={key} className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={state}
onChange={(e) => setState(e.target.checked)}
onChange={(e) => {
setState(e.target.checked)
setSelectedPresetId('') // Reset preset se modifica manuale
}}
className="h-4 w-4 rounded border-input"
/>
<span className="text-sm">{label}</span>
@@ -8,6 +8,8 @@ import { Label } from '@/components/ui/Label'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog'
import { routingRulesApi, type RoutingRuleResponse, type RoutingRuleCreate, type ConditionField, type ConditionOperator, type ActionType } from '@/api/routing_rules.api'
import { labelsApi } from '@/api/labels.api'
import type { LabelResponse, LabelTreeResponse } from '@/types/api.types'
import { RISK_LEVEL_OPTIONS, CONFIDENTIALITY_OPTIONS } from '@/components/RiskBadge/RiskBadge'
import { getErrorMessage } from '@/api/client'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
@@ -18,6 +20,10 @@ const FIELD_LABELS: Record<ConditionField, string> = {
subject: 'Oggetto',
mailbox_id: 'ID Casella',
pec_type: 'Tipo PEC',
has_label: 'Ha etichetta/classificazione',
// Rischio e Riservatezza (N3)
risk_level: 'Livello di rischio',
confidentiality: 'Riservatezza',
}
const OPERATOR_LABELS: Record<ConditionOperator, string> = {
@@ -35,6 +41,26 @@ const ACTION_LABELS: Record<ActionType, string> = {
mark_read: 'Segna come letto',
mark_starred: 'Aggiungi ai preferiti',
notify_webhook: 'Notifica webhook',
apply_taxonomy: 'Applica classificazione tassonomica',
// Rischio e Riservatezza (N3)
set_risk_level: 'Imposta livello di rischio',
set_confidentiality: 'Imposta riservatezza',
}
/** Costruisce il percorso completo di una label: "Ambito > Processo > Classificazione" */
function buildLabelPath(labelId: string, allLabels: LabelResponse[]): string {
const map = new Map(allLabels.map((l) => [l.id, l]))
const parts: string[] = []
let current = map.get(labelId)
while (current) {
parts.unshift(current.name)
if (current.parent_id) {
current = map.get(current.parent_id)
} else {
break
}
}
return parts.join(' > ')
}
interface Condition {
@@ -325,12 +351,27 @@ export function RoutingRulesPage() {
>
{(Object.entries(OPERATOR_LABELS) as [ConditionOperator, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
<Input
className="flex-1"
value={cond.value}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: e.target.value } : c))}
placeholder="Valore..."
/>
{cond.field === 'has_label' ? (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.value}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: e.target.value } : c))}
>
<option value="">-- Seleziona etichetta --</option>
{labels.map(l => (
<option key={l.id} value={l.id}>
{buildLabelPath(l.id, labels) || l.name}
</option>
))}
</select>
) : (
<Input
className="flex-1"
value={cond.value}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, value: e.target.value } : c))}
placeholder="Valore..."
/>
)}
<button type="button" onClick={() => setFormConditions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
<Trash2 className="h-4 w-4" />
</button>
@@ -362,7 +403,21 @@ export function RoutingRulesPage() {
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
>
<option value="">-- Seleziona etichetta --</option>
{labels.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
{labels.map((l: LabelResponse) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
)}
{(action.action_type === 'apply_taxonomy') && (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
>
<option value="">-- Seleziona classificazione --</option>
{labels.map((l: LabelResponse) => (
<option key={l.id} value={l.id}>
{buildLabelPath(l.id, labels) || l.name}
</option>
))}
</select>
)}
{(action.action_type === 'notify_webhook') && (
@@ -373,6 +428,31 @@ export function RoutingRulesPage() {
placeholder="https://..."
/>
)}
{/* Rischio e Riservatezza (N3) */}
{(action.action_type === 'set_risk_level') && (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
>
<option value="">-- Seleziona livello --</option>
{RISK_LEVEL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)}
{(action.action_type === 'set_confidentiality') && (
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
>
<option value="">-- Seleziona riservatezza --</option>
{CONFIDENTIALITY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)}
<button type="button" onClick={() => setFormActions(prev => prev.filter((_, idx) => idx !== i))} className="p-1 text-destructive hover:bg-destructive/10 rounded">
<Trash2 className="h-4 w-4" />
</button>
@@ -0,0 +1,454 @@
/**
* TaxonomyPage Gestione della Tassonomia di Classificazione Multi-livello (Feature N2).
*
* Struttura gerarchica:
* Livello 0 (Radice) = Ambito (Area Aziendale) — es. "Legale", "Commerciale"
* Livello 1 = Processo — es. "Contratti", "Contenzioso"
* Livello 2 (Foglia) = Classificazione — es. "NDA", "Ricorso", "Fattura"
*
* Le classificazioni vengono poi assegnate ai messaggi tramite:
* - Widget tassonomia nel dettaglio messaggio (selezione manuale)
* - Regole di smistamento con azione "apply_taxonomy" (automatica)
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
ChevronRight,
ChevronDown,
Plus,
Pencil,
Trash2,
Tags,
Info,
X,
} from 'lucide-react'
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 { labelsApi } from '@/api/labels.api'
import type { LabelTreeResponse } from '@/types/api.types'
import { getErrorMessage } from '@/api/client'
import { cn } from '@/lib/utils'
// ─── Livelli della tassonomia ─────────────────────────────────────────────────
const LEVEL_LABELS = ['Ambito', 'Processo', 'Classificazione']
const LEVEL_COLORS = [
'bg-blue-100 text-blue-800 border-blue-200',
'bg-purple-100 text-purple-800 border-purple-200',
'bg-green-100 text-green-800 border-green-200',
]
const LEVEL_BG = [
'border-l-2 border-blue-400',
'border-l-2 border-purple-400 ml-6',
'border-l-2 border-green-400 ml-12',
]
// ─── Calcola la profondita' di un nodo nell'albero ────────────────────────────
function getNodeDepth(node: LabelTreeResponse): number {
if (!node.parent_id) return 0
return -1 // verrà calcolato nel contesto dell'albero
}
// ─── Componente nodo dell'albero ──────────────────────────────────────────────
interface TreeNodeProps {
node: LabelTreeResponse
depth: number
onAddChild: (parent: LabelTreeResponse, depth: number) => void
onEdit: (node: LabelTreeResponse, depth: number) => void
onDelete: (node: LabelTreeResponse) => void
}
function TreeNode({ node, depth, onAddChild, onEdit, onDelete }: TreeNodeProps) {
const [expanded, setExpanded] = useState(true)
const hasChildren = node.children.length > 0
const levelLabel = LEVEL_LABELS[depth] ?? `Livello ${depth}`
const levelColor = LEVEL_COLORS[depth] ?? 'bg-gray-100 text-gray-700 border-gray-200'
const levelBg = LEVEL_BG[depth] ?? 'border-l-2 border-gray-400 ml-16'
return (
<div className={cn(levelBg, 'pl-3 py-0.5')}>
<div className="flex items-center gap-2 py-2 px-2 rounded-lg hover:bg-muted/40 group">
{/* Espandi/comprimi */}
{hasChildren ? (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="p-0.5 rounded hover:bg-muted text-muted-foreground"
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
) : (
<span className="w-5" />
)}
{/* Pallino colore */}
{node.color && (
<span
className="h-3.5 w-3.5 rounded-full flex-shrink-0 border"
style={{ backgroundColor: node.color }}
/>
)}
{/* Nome */}
<span className="flex-1 text-sm font-medium">{node.name}</span>
{/* Badge livello */}
<span className={cn('text-xs px-2 py-0.5 rounded-full border font-medium', levelColor)}>
{levelLabel}
</span>
{/* Descrizione (tooltip) */}
{node.description && (
<span title={node.description} className="text-muted-foreground cursor-help">
<Info className="h-3.5 w-3.5" />
</span>
)}
{/* Azioni — visibili solo al hover */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{depth < 2 && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={`Aggiungi ${LEVEL_LABELS[depth + 1] ?? 'sottonodo'}`}
onClick={() => onAddChild(node, depth + 1)}
>
<Plus className="h-3.5 w-3.5 text-primary" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Modifica"
onClick={() => onEdit(node, depth)}
>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Elimina"
onClick={() => onDelete(node)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
{/* Figli */}
{expanded && hasChildren && (
<div className="mt-0.5">
{node.children.map((child) => (
<TreeNode
key={child.id}
node={child}
depth={depth + 1}
onAddChild={onAddChild}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
)}
</div>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function TaxonomyPage() {
const queryClient = useQueryClient()
// Stato form
const [showForm, setShowForm] = useState(false)
const [formName, setFormName] = useState('')
const [formColor, setFormColor] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formParentId, setFormParentId] = useState<string | null>(null)
const [formParentName, setFormParentName] = useState<string>('')
const [formDepth, setFormDepth] = useState(0)
const [editingId, setEditingId] = useState<string | null>(null)
// Carica albero tassonomico
const { data: tree = [], isLoading } = useQuery({
queryKey: ['labels-tree'],
queryFn: () => labelsApi.getTree(),
})
// Conta i nodi totali
function countNodes(nodes: LabelTreeResponse[]): number {
return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children), 0)
}
const totalNodes = countNodes(tree)
// Crea nodo
const createMutation = useMutation({
mutationFn: (data: { name: string; color?: string; description?: string; parent_id?: string | null }) =>
labelsApi.create({ name: data.name, color: data.color || null, description: data.description || null, parent_id: data.parent_id }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['labels-tree'] })
queryClient.invalidateQueries({ queryKey: ['labels'] })
toast.success('Nodo creato')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
// Aggiorna nodo
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: { name: string; color?: string; description?: string } }) =>
labelsApi.update(id, { name: data.name, color: data.color || null, description: data.description || null }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['labels-tree'] })
queryClient.invalidateQueries({ queryKey: ['labels'] })
toast.success('Nodo aggiornato')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
// Elimina nodo (elimina anche tutti i figli in cascata)
const deleteMutation = useMutation({
mutationFn: (id: string) => labelsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['labels-tree'] })
queryClient.invalidateQueries({ queryKey: ['labels'] })
toast.success('Nodo eliminato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
function openCreate(parentNode: LabelTreeResponse | null = null, depth = 0) {
setEditingId(null)
setFormName('')
setFormColor('')
setFormDescription('')
setFormParentId(parentNode?.id ?? null)
setFormParentName(parentNode?.name ?? '')
setFormDepth(depth)
setShowForm(true)
}
function openEdit(node: LabelTreeResponse, depth: number) {
setEditingId(node.id)
setFormName(node.name)
setFormColor(node.color ?? '')
setFormDescription(node.description ?? '')
setFormParentId(node.parent_id)
setFormParentName('')
setFormDepth(depth)
setShowForm(true)
}
function closeForm() {
setShowForm(false)
setEditingId(null)
setFormName('')
setFormColor('')
setFormDescription('')
setFormParentId(null)
setFormParentName('')
}
function handleDelete(node: LabelTreeResponse) {
const hasChildren = node.children.length > 0
const msg = hasChildren
? `Eliminare "${node.name}" e tutti i suoi ${node.children.length} sottonodi? Verranno rimossi anche dai messaggi.`
: `Eliminare "${node.name}"? Verra' rimosso anche dai messaggi.`
if (confirm(msg)) {
deleteMutation.mutate(node.id)
}
}
function handleSubmit() {
if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
if (formColor && !/^#[0-9A-Fa-f]{6}$/.test(formColor)) {
return toast.error('Il colore deve essere in formato esadecimale #RRGGBB')
}
if (editingId) {
updateMutation.mutate({
id: editingId,
data: { name: formName.trim(), color: formColor, description: formDescription.trim() },
})
} else {
createMutation.mutate({
name: formName.trim(),
color: formColor,
description: formDescription.trim(),
parent_id: formParentId,
})
}
}
const levelLabel = LEVEL_LABELS[formDepth] ?? `Livello ${formDepth}`
const levelColor = LEVEL_COLORS[formDepth] ?? 'bg-gray-100 text-gray-700'
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>
<h1 className="text-xl font-semibold flex items-center gap-2">
<Tags className="h-5 w-5 text-primary" />
Tassonomia di Classificazione
</h1>
<p className="text-sm text-muted-foreground">
Organizza la classificazione dei messaggi in una struttura gerarchica a 3 livelli
</p>
</div>
<Button onClick={() => openCreate(null, 0)}>
<Plus className="h-4 w-4 mr-2" />
Nuovo Ambito
</Button>
</div>
{/* Legenda livelli */}
<div className="px-6 py-3 border-b bg-muted/20 flex items-center gap-6 text-sm flex-wrap">
{LEVEL_LABELS.map((label, i) => (
<div key={label} className="flex items-center gap-2">
<span className={cn('px-2 py-0.5 rounded-full border text-xs font-medium', LEVEL_COLORS[i])}>
{label}
</span>
<span className="text-muted-foreground text-xs">
{i === 0 ? 'Area aziendale principale' : i === 1 ? 'Processo / sotto-area' : 'Classificazione assegnabile ai messaggi'}
</span>
</div>
))}
<span className="ml-auto text-xs text-muted-foreground">{totalNodes} nodi totali</span>
</div>
{/* Albero */}
<div className="flex-1 overflow-y-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>
) : tree.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Tags className="h-14 w-14 mx-auto mb-4 opacity-20" />
<p className="text-lg font-medium">Nessuna tassonomia configurata</p>
<p className="text-sm mt-1 max-w-md mx-auto">
Crea un Ambito per iniziare a strutturare la classificazione dei messaggi PEC.
Ogni Ambito puo' contenere Processi, che a loro volta contengono Classificazioni.
</p>
<Button className="mt-6" onClick={() => openCreate(null, 0)}>
<Plus className="h-4 w-4 mr-2" />
Crea il primo Ambito
</Button>
</div>
) : (
<div className="space-y-2 max-w-3xl">
{tree.map((root) => (
<TreeNode
key={root.id}
node={root}
depth={0}
onAddChild={(parent, depth) => openCreate(parent, depth)}
onEdit={openEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
{/* Dialog crea/modifica nodo */}
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{editingId ? 'Modifica nodo' : `Nuovo ${levelLabel}`}
<span className={cn('text-xs px-2 py-0.5 rounded-full border font-normal', levelColor)}>
{levelLabel}
</span>
</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-2">
{/* Parent path (solo in creazione) */}
{!editingId && formParentName && (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/40 rounded-lg px-3 py-2">
<Tags className="h-4 w-4 flex-shrink-0" />
<span>Sotto: <strong>{formParentName}</strong></span>
</div>
)}
<div className="space-y-2">
<Label>Nome *</Label>
<Input
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder={`Es. ${levelLabel === 'Ambito' ? 'Legale' : levelLabel === 'Processo' ? 'Contratti' : 'NDA'}`}
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Descrizione (opzionale)</Label>
<Input
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Breve descrizione del nodo..."
/>
</div>
<div className="space-y-2">
<Label>Colore (opzionale)</Label>
<div className="flex items-center gap-3">
<Input
value={formColor}
onChange={(e) => setFormColor(e.target.value)}
placeholder="#3B82F6"
maxLength={7}
className="font-mono"
/>
{formColor && /^#[0-9A-Fa-f]{6}$/.test(formColor) && (
<div
className="h-8 w-8 rounded-md border flex-shrink-0"
style={{ backgroundColor: formColor }}
/>
)}
{/* Palette colori rapidi */}
<div className="flex gap-1">
{['#3B82F6', '#8B5CF6', '#10B981', '#F59E0B', '#EF4444', '#6B7280'].map((c) => (
<button
key={c}
type="button"
onClick={() => setFormColor(c)}
className="h-6 w-6 rounded-full border-2 hover:scale-110 transition-transform"
style={{ backgroundColor: c, borderColor: formColor === c ? '#1e40af' : 'transparent' }}
title={c}
/>
))}
</div>
</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={closeForm}>Annulla</Button>
<Button
onClick={handleSubmit}
isLoading={createMutation.isPending || updateMutation.isPending}
>
{editingId ? 'Salva modifiche' : `Crea ${levelLabel}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+59 -4
View File
@@ -170,23 +170,35 @@ export interface ConnectionTestResult {
capabilities: string[] | null
}
// ─── Label (Tag) ──────────────────────────────────────────────────────────────
// ─── Label (Tag) + Tassonomia (N2) ───────────────────────────────────────────
export interface LabelResponse {
id: string
tenant_id: string
name: string
color: string | null
// Tassonomia: se valorizzato, questo nodo fa parte di un albero gerarchico
parent_id: string | null
description: string | null
}
/** Nodo tassonomico con figli annidati — restituito da GET /labels/tree */
export interface LabelTreeResponse extends LabelResponse {
children: LabelTreeResponse[]
}
export interface LabelCreate {
name: string
color?: string | null
parent_id?: string | null
description?: string | null
}
export interface LabelUpdate {
name?: string
color?: string | null
parent_id?: string | null
description?: string | null
}
export interface MessageLabelSetRequest {
@@ -224,6 +236,11 @@ export interface SearchMatchInfo {
in_attachments: AttachmentMatchInfo[]
}
// ─── Rischio e Riservatezza (Feature N3) ─────────────────────────────────────
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical'
export type ConfidentialityLevel = 'public' | 'internal' | 'confidential' | 'secret'
// ─── Message ──────────────────────────────────────────────────────────────────
export type PecDirection = 'inbound' | 'outbound'
@@ -279,6 +296,9 @@ export interface MessageResponse {
pending_conservation_at: string | null
is_conserved: boolean
conserved_at: string | null
// Rischio e Riservatezza (Feature N3)
risk_level: RiskLevel | null
confidentiality: ConfidentialityLevel | null
raw_eml_path: string | null
created_at: string
updated_at: string
@@ -353,6 +373,7 @@ export interface PermissionResponse {
can_read: boolean
can_send: boolean
can_manage: boolean
can_conserve: boolean
granted_by: string | null
granted_at: string
}
@@ -366,9 +387,9 @@ export interface PermissionGrantRequest {
export interface MailboxUserPermissionResponse {
user_id: string
email: string
full_name: string
role: UserRole
user_email: string
user_full_name: string
user_role: UserRole
can_read: boolean
can_send: boolean
can_manage: boolean
@@ -376,6 +397,40 @@ export interface MailboxUserPermissionResponse {
granted_at: string
}
// ─── Permission Preset (sottoruoli) ───────────────────────────────────────────
export interface PermissionPresetResponse {
id: string
tenant_id: string
name: string
description: string | null
can_read: boolean
can_send: boolean
can_manage: boolean
can_conserve: boolean
created_by: string | null
created_at: string
updated_at: string
}
export interface PermissionPresetCreate {
name: string
description?: string | null
can_read?: boolean
can_send?: boolean
can_manage?: boolean
can_conserve?: boolean
}
export interface PermissionPresetUpdate {
name?: string
description?: string | null
can_read?: boolean
can_send?: boolean
can_manage?: boolean
can_conserve?: boolean
}
export interface UserMailboxPermissionResponse {
mailbox_id: string
email_address: string