Fascicoli+Tassonomia+permessi
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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}`),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 > Processo > 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user