Implementazioni varie

This commit is contained in:
2026-03-27 20:59:06 +01:00
parent 047990811f
commit 46784aca4c
40 changed files with 4090 additions and 34 deletions
+10
View File
@@ -14,6 +14,10 @@ import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage'
import { SearchPage } from '@/pages/Search/SearchPage'
import { ReportsPage } from '@/pages/Reports/ReportsPage'
import { AuditLogPage } from '@/pages/AuditLog/AuditLogPage'
import { TemplatesPage } from '@/pages/Templates/TemplatesPage'
import { RoutingRulesPage } from '@/pages/RoutingRules/RoutingRulesPage'
import { ContactsPage } from '@/pages/Contacts/ContactsPage'
import { DeadlinesPage } from '@/pages/Deadlines/DeadlinesPage'
/**
* Routing principale dell'applicazione PEChub.
@@ -92,6 +96,12 @@ export default function App() {
{/* Audit Log */}
<Route path="/audit-log" element={<AuditLogPage />} />
{/* Nuove funzionalita' */}
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/routing-rules" element={<RoutingRulesPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/deadlines" element={<DeadlinesPage />} />
{/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} />
+65
View File
@@ -0,0 +1,65 @@
import apiClient from './client'
export interface PecContactResponse {
id: string
tenant_id: string
email: string
name: string | null
organization: string | null
notes: string | null
is_favorite: boolean
auto_saved: boolean
created_by: string | null
created_at: string
updated_at: string
}
export interface PecContactCreate {
email: string
name?: string | null
organization?: string | null
notes?: string | null
is_favorite?: boolean
}
export interface PecContactUpdate {
name?: string | null
organization?: string | null
notes?: string | null
is_favorite?: boolean
}
export interface ContactImportResult {
created: number
updated: number
skipped: number
errors: string[]
}
export const contactsApi = {
list: (params?: { q?: string; page?: number; page_size?: number }) =>
apiClient.get<{ items: PecContactResponse[]; total: number }>('/contacts', { params }).then((r) => r.data),
autocomplete: (q: string) =>
apiClient.get<PecContactResponse[]>('/contacts/autocomplete', { params: { q } }).then((r) => r.data),
get: (id: string) =>
apiClient.get<PecContactResponse>(`/contacts/${id}`).then((r) => r.data),
create: (data: PecContactCreate) =>
apiClient.post<PecContactResponse>('/contacts', data).then((r) => r.data),
update: (id: string, data: PecContactUpdate) =>
apiClient.put<PecContactResponse>(`/contacts/${id}`, data).then((r) => r.data),
delete: (id: string) =>
apiClient.delete(`/contacts/${id}`).then((r) => r.data),
importCsv: (file: File) => {
const formData = new FormData()
formData.append('file', file)
return apiClient.post<ContactImportResult>('/contacts/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then((r) => r.data)
},
}
+31
View File
@@ -0,0 +1,31 @@
import apiClient from './client'
export interface DeadlineMessageResponse {
id: string
subject: string | null
from_address: string | null
to_addresses: string[] | null
direction: 'inbound' | 'outbound'
pec_type: string
state: string
mailbox_id: string
deadline_at: string | null
deadline_note: string | null
is_overdue: boolean
received_at: string | null
sent_at: string | null
created_at: string
}
export interface DeadlineSetRequest {
deadline_at: string | null
deadline_note?: string | null
}
export const deadlinesApi = {
list: (params?: { days_ahead?: number; include_overdue?: boolean }) =>
apiClient.get<DeadlineMessageResponse[]>('/deadlines', { params }).then((r) => r.data),
setDeadline: (messageId: string, data: DeadlineSetRequest) =>
apiClient.post<DeadlineMessageResponse>(`/messages/${messageId}/deadline`, data).then((r) => r.data),
}
+23
View File
@@ -128,6 +128,29 @@ export const messagesApi = {
getReceipts: (id: string) =>
apiClient.get<MessageResponse[]>(`/messages/${id}/receipts`).then((r) => r.data),
// ─── Feature 3: Thread ────────────────────────────────────────────────────
getThread: (id: string) =>
apiClient.get<MessageResponse[]>(`/messages/${id}/thread`).then((r) => r.data),
// ─── Feature 7: Preview allegati ─────────────────────────────────────────
getAttachmentPreviewUrl: (messageId: string, attachmentId: string) =>
apiClient.get<{
previewable: boolean
content_type: string
filename: string
url?: string
}>(`/messages/${messageId}/attachments/${attachmentId}/preview-url`).then((r) => r.data),
// ─── Feature 8: Stampa ────────────────────────────────────────────────────
/** Apre la vista di stampa HTML in una nuova tab. */
openPrint: (messageId: string, token: string) => {
const baseUrl = (window as any).__API_BASE_URL__ || '/api/v1'
window.open(`${baseUrl}/messages/${messageId}/print?token=${token}`, '_blank')
},
/**
* Scarica il pacchetto ZIP completo della PEC (postacert.eml, daticert.xml,
* ricevute di accettazione/consegna per le mail outbound).
+65
View File
@@ -0,0 +1,65 @@
import apiClient from './client'
export type ConditionField = 'from_address' | 'to_address' | 'subject' | 'mailbox_id' | 'pec_type'
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 interface RoutingRuleCondition {
id: string
field: ConditionField
operator: ConditionOperator
value: string
}
export interface RoutingRuleAction {
id: string
action_type: ActionType
action_value: string | null
}
export interface RoutingRuleResponse {
id: string
tenant_id: string
name: string
description: string | null
is_active: boolean
priority: number
stop_processing: boolean
conditions: RoutingRuleCondition[]
actions: RoutingRuleAction[]
created_by: string | null
created_at: string
updated_at: string
}
export interface RoutingRuleCreate {
name: string
description?: string | null
is_active?: boolean
priority?: number
stop_processing?: boolean
conditions?: Array<{ field: ConditionField; operator: ConditionOperator; value: string }>
actions?: Array<{ action_type: ActionType; action_value?: string | null }>
}
export type RoutingRuleUpdate = Partial<RoutingRuleCreate>
export const routingRulesApi = {
list: () =>
apiClient.get<{ items: RoutingRuleResponse[]; total: number }>('/routing-rules').then((r) => r.data),
get: (id: string) =>
apiClient.get<RoutingRuleResponse>(`/routing-rules/${id}`).then((r) => r.data),
create: (data: RoutingRuleCreate) =>
apiClient.post<RoutingRuleResponse>('/routing-rules', data).then((r) => r.data),
update: (id: string, data: RoutingRuleUpdate) =>
apiClient.put<RoutingRuleResponse>(`/routing-rules/${id}`, data).then((r) => r.data),
delete: (id: string) =>
apiClient.delete(`/routing-rules/${id}`).then((r) => r.data),
toggle: (id: string) =>
apiClient.post<RoutingRuleResponse>(`/routing-rules/${id}/toggle`).then((r) => r.data),
}
+49
View File
@@ -0,0 +1,49 @@
import apiClient from './client'
export interface TemplateResponse {
id: string
tenant_id: string
name: string
description: string | null
subject: string
body_text: string | null
body_html: string | null
created_by: string | null
created_at: string
updated_at: string
}
export interface TemplateCreate {
name: string
description?: string | null
subject?: string
body_text?: string | null
body_html?: string | null
}
export interface TemplateUpdate {
name?: string
description?: string | null
subject?: string
body_text?: string | null
body_html?: string | null
}
export const templatesApi = {
list: (q?: string) =>
apiClient.get<{ items: TemplateResponse[]; total: number }>('/templates', {
params: { q },
}).then((r) => r.data),
get: (id: string) =>
apiClient.get<TemplateResponse>(`/templates/${id}`).then((r) => r.data),
create: (data: TemplateCreate) =>
apiClient.post<TemplateResponse>('/templates', data).then((r) => r.data),
update: (id: string, data: TemplateUpdate) =>
apiClient.put<TemplateResponse>(`/templates/${id}`, data).then((r) => r.data),
delete: (id: string) =>
apiClient.delete(`/templates/${id}`).then((r) => r.data),
}
+39 -1
View File
@@ -54,6 +54,10 @@ import {
BarChart2,
ClipboardList,
ShieldCheck,
FileText,
Settings2,
BookUser,
Calendar,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
@@ -403,10 +407,42 @@ export function Sidebar() {
</div>
)}
{/* ── Ricerca avanzata + Dashboard ── */}
{/* ── Strumenti operativi ── */}
<div>
<div className="border-t border-gray-700 mx-4 mb-3" />
<div className="px-2 space-y-0.5">
<NavLink
to="/deadlines"
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 ? 'Scadenzario' : undefined}
>
<Calendar className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Scadenzario</span>}
</NavLink>
<NavLink
to="/contacts"
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 ? 'Rubrica PEC' : undefined}
>
<BookUser className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Rubrica PEC</span>}
</NavLink>
<NavLink
to="/search"
className={({ isActive }) =>
@@ -518,6 +554,8 @@ export function Sidebar() {
{ to: '/users', label: 'Utenti', icon: Users },
{ to: '/permissions', label: 'Permessi', icon: Shield },
{ to: '/virtual-boxes', label: 'Virtual Box', icon: Filter },
{ to: '/routing-rules', label: 'Regole smistamento', icon: Settings2 },
{ to: '/templates', label: 'Template messaggi', icon: FileText },
{ to: '/notifications', label: 'Notifiche', icon: Bell },
{ to: '/audit-log', label: 'Audit Log', icon: ClipboardList },
] as const).map((item) => (
@@ -0,0 +1,282 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Star, Trash2, Pencil, Upload, BookUser, Building2 } 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 { contactsApi, type PecContactResponse, type PecContactCreate, type PecContactUpdate } from '@/api/contacts.api'
import { getErrorMessage } from '@/api/client'
import { formatDate } from '@/lib/utils'
export function ContactsPage() {
const queryClient = useQueryClient()
const [q, setQ] = useState('')
const [page] = useState(1)
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<PecContactResponse | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Form state
const [formEmail, setFormEmail] = useState('')
const [formName, setFormName] = useState('')
const [formOrg, setFormOrg] = useState('')
const [formNotes, setFormNotes] = useState('')
const [formFavorite, setFormFavorite] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['contacts', q, page],
queryFn: () => contactsApi.list({ q: q || undefined, page, page_size: 50 }),
})
const createMutation = useMutation({
mutationFn: (d: PecContactCreate) => contactsApi.create(d),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contacts'] })
toast.success('Contatto aggiunto')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: PecContactUpdate }) =>
contactsApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contacts'] })
toast.success('Contatto aggiornato')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => contactsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['contacts'] })
toast.success('Contatto eliminato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const importMutation = useMutation({
mutationFn: (file: File) => contactsApi.importCsv(file),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['contacts'] })
toast.success(`Importati: ${res.created} nuovi, ${res.updated} aggiornati, ${res.skipped} saltati`)
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggleFavMutation = useMutation({
mutationFn: ({ id, fav }: { id: string; fav: boolean }) =>
contactsApi.update(id, { is_favorite: fav }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['contacts'] }),
onError: (e) => toast.error(getErrorMessage(e)),
})
const openCreate = () => {
setEditing(null)
setFormEmail('')
setFormName('')
setFormOrg('')
setFormNotes('')
setFormFavorite(false)
setShowForm(true)
}
const openEdit = (c: PecContactResponse) => {
setEditing(c)
setFormEmail(c.email)
setFormName(c.name ?? '')
setFormOrg(c.organization ?? '')
setFormNotes(c.notes ?? '')
setFormFavorite(c.is_favorite)
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditing(null)
}
const handleSubmit = () => {
if (!formEmail.trim()) return toast.error('L\'email e\' obbligatoria')
const payload = {
email: formEmail.trim(),
name: formName.trim() || null,
organization: formOrg.trim() || null,
notes: formNotes.trim() || null,
is_favorite: formFavorite,
}
if (editing) {
updateMutation.mutate({ id: editing.id, data: payload })
} else {
createMutation.mutate(payload)
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
importMutation.mutate(file)
e.target.value = ''
}
}
const items = data?.items ?? []
const total = data?.total ?? 0
return (
<div className="flex flex-col h-full">
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">Rubrica PEC</h1>
<p className="text-sm text-muted-foreground">
{total} contatti nella rubrica
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => fileInputRef.current?.click()} isLoading={importMutation.isPending}>
<Upload className="h-4 w-4 mr-2" />
Importa CSV
</Button>
<input ref={fileInputRef} type="file" accept=".csv" className="hidden" onChange={handleFileChange} />
<Button onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
Nuovo contatto
</Button>
</div>
</div>
<div className="p-6 space-y-4 flex-1 overflow-y-auto">
<div className="relative w-full max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cerca per email, nome o organizzazione..."
value={q}
onChange={(e) => setQ(e.target.value)}
className="pl-9"
/>
</div>
{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>
) : items.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<BookUser className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">Nessun contatto trovato</p>
<p className="text-sm mt-1">
Aggiungi contatti manualmente o importa un CSV con colonne: email, name, organization
</p>
</div>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Email PEC</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Nome</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Organizzazione</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Tipo</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Aggiornato</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y">
{items.map((c) => (
<tr key={c.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-mono text-xs">{c.email}</td>
<td className="px-4 py-3">{c.name ?? '-'}</td>
<td className="px-4 py-3 text-muted-foreground">
{c.organization ? (
<span className="flex items-center gap-1">
<Building2 className="h-3.5 w-3.5" />
{c.organization}
</span>
) : '-'}
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${c.auto_saved ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}`}>
{c.auto_saved ? 'Automatico' : 'Manuale'}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(c.updated_at)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 justify-end">
<button
onClick={() => toggleFavMutation.mutate({ id: c.id, fav: !c.is_favorite })}
title={c.is_favorite ? 'Rimuovi dai preferiti' : 'Aggiungi ai preferiti'}
className="p-1 rounded hover:bg-muted transition-colors"
>
<Star className={`h-4 w-4 ${c.is_favorite ? 'fill-yellow-400 text-yellow-400' : 'text-muted-foreground'}`} />
</button>
<button
onClick={() => openEdit(c)}
className="p-1 rounded hover:bg-muted transition-colors"
title="Modifica"
>
<Pencil className="h-4 w-4 text-muted-foreground" />
</button>
<button
onClick={() => {
if (confirm(`Eliminare il contatto ${c.email}?`)) deleteMutation.mutate(c.id)
}}
className="p-1 rounded hover:bg-muted transition-colors"
title="Elimina"
>
<Trash2 className="h-4 w-4 text-destructive" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editing ? 'Modifica contatto' : 'Nuovo contatto'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Indirizzo PEC *</Label>
<Input value={formEmail} onChange={(e) => setFormEmail(e.target.value)} placeholder="indirizzo@pec.it" disabled={!!editing} type="email" />
</div>
<div className="space-y-2">
<Label>Nome</Label>
<Input value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="Mario Rossi" />
</div>
<div className="space-y-2">
<Label>Organizzazione</Label>
<Input value={formOrg} onChange={(e) => setFormOrg(e.target.value)} placeholder="Comune di Roma" />
</div>
<div className="space-y-2">
<Label>Note</Label>
<Input value={formNotes} onChange={(e) => setFormNotes(e.target.value)} placeholder="Note aggiuntive..." />
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={formFavorite} onChange={(e) => setFormFavorite(e.target.checked)} className="rounded" />
<span className="text-sm">Aggiungi ai preferiti</span>
</label>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={closeForm}>Annulla</Button>
<Button onClick={handleSubmit} isLoading={createMutation.isPending || updateMutation.isPending}>
{editing ? 'Salva' : 'Aggiungi'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,190 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { Calendar, AlertTriangle, Clock, CheckCircle2, ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { deadlinesApi, type DeadlineMessageResponse } from '@/api/deadlines.api'
import { formatDate } from '@/lib/utils'
function groupDeadlines(items: DeadlineMessageResponse[]) {
const now = new Date()
const todayEnd = new Date(now)
todayEnd.setHours(23, 59, 59, 999)
const weekEnd = new Date(now)
weekEnd.setDate(weekEnd.getDate() + 7)
const monthEnd = new Date(now)
monthEnd.setDate(monthEnd.getDate() + 30)
const overdue: DeadlineMessageResponse[] = []
const today: DeadlineMessageResponse[] = []
const thisWeek: DeadlineMessageResponse[] = []
const later: DeadlineMessageResponse[] = []
for (const item of items) {
if (!item.deadline_at) continue
const d = new Date(item.deadline_at)
if (d < now) {
overdue.push(item)
} else if (d <= todayEnd) {
today.push(item)
} else if (d <= weekEnd) {
thisWeek.push(item)
} else {
later.push(item)
}
}
return { overdue, today, thisWeek, later }
}
function DeadlineItem({ item }: { item: DeadlineMessageResponse }) {
const navigate = useNavigate()
const deadlineDate = item.deadline_at ? new Date(item.deadline_at) : null
return (
<div
className={`flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow cursor-pointer ${item.is_overdue ? 'border-destructive/30 bg-destructive/5' : ''}`}
onClick={() => navigate(`/messages/${item.id}`)}
>
<div className="flex-shrink-0">
{item.is_overdue ? (
<AlertTriangle className="h-5 w-5 text-destructive" />
) : (
<Clock className="h-5 w-5 text-amber-500" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.subject || '(nessun oggetto)'}</p>
<p className="text-xs text-muted-foreground">
{item.direction === 'inbound' ? `Da: ${item.from_address}` : `A: ${(item.to_addresses ?? []).join(', ')}`}
</p>
{item.deadline_note && (
<p className="text-xs text-muted-foreground italic mt-0.5">{item.deadline_note}</p>
)}
</div>
<div className="flex-shrink-0 text-right">
<p className={`text-sm font-semibold ${item.is_overdue ? 'text-destructive' : 'text-amber-600'}`}>
{deadlineDate ? formatDate(deadlineDate.toISOString()) : '-'}
</p>
{item.is_overdue && (
<p className="text-xs text-destructive">Scaduto</p>
)}
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div>
)
}
function DeadlineGroup({ title, items, icon: Icon, color }: {
title: string
items: DeadlineMessageResponse[]
icon: React.ComponentType<{ className?: string }>
color: string
}) {
if (items.length === 0) return null
return (
<div className="space-y-2">
<div className={`flex items-center gap-2 ${color}`}>
<Icon className="h-4 w-4" />
<h3 className="font-semibold text-sm">{title} ({items.length})</h3>
</div>
<div className="space-y-2">
{items.map((item) => (
<DeadlineItem key={item.id} item={item} />
))}
</div>
</div>
)
}
export function DeadlinesPage() {
const [daysAhead, setDaysAhead] = useState(30)
const [includeOverdue, setIncludeOverdue] = useState(true)
const { data = [], isLoading } = useQuery({
queryKey: ['deadlines', daysAhead, includeOverdue],
queryFn: () => deadlinesApi.list({ days_ahead: daysAhead, include_overdue: includeOverdue }),
})
const groups = groupDeadlines(data)
const total = data.length
return (
<div className="flex flex-col h-full">
<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">
<Calendar className="h-5 w-5 text-primary" />
Scadenzario
</h1>
<p className="text-sm text-muted-foreground">
{total} messaggi con scadenze
</p>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={includeOverdue}
onChange={(e) => setIncludeOverdue(e.target.checked)}
className="rounded"
/>
Includi scaduti
</label>
<select
value={daysAhead}
onChange={(e) => setDaysAhead(Number(e.target.value))}
className="text-sm border rounded-md px-3 py-1.5 bg-background"
>
<option value={7}>Prossimi 7 giorni</option>
<option value={30}>Prossimi 30 giorni</option>
<option value={90}>Prossimi 90 giorni</option>
<option value={365}>Prossimo anno</option>
</select>
</div>
</div>
<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>
) : total === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<CheckCircle2 className="h-12 w-12 mx-auto mb-4 opacity-30 text-green-500" />
<p className="text-lg font-medium">Nessuna scadenza trovata</p>
<p className="text-sm mt-1">
Le scadenze si impostano dal dettaglio di ogni messaggio.
</p>
</div>
) : (
<div className="max-w-3xl mx-auto space-y-8">
<DeadlineGroup
title="Scaduti"
items={groups.overdue}
icon={AlertTriangle}
color="text-destructive"
/>
<DeadlineGroup
title="Oggi"
items={groups.today}
icon={Clock}
color="text-amber-600"
/>
<DeadlineGroup
title="Questa settimana"
items={groups.thisWeek}
icon={Calendar}
color="text-blue-600"
/>
<DeadlineGroup
title="Successivamente"
items={groups.later}
icon={Calendar}
color="text-muted-foreground"
/>
</div>
)}
</div>
</div>
)
}
@@ -17,17 +17,132 @@ import {
RotateCcw,
MailX,
PackageOpen,
Printer,
Calendar,
Eye,
MessageSquare,
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 { 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 { messagesApi } from '@/api/messages.api'
import { labelsApi } from '@/api/labels.api'
import { deadlinesApi } from '@/api/deadlines.api'
import { formatDate, formatBytes } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
import apiClient from '@/api/client'
// ─── Thread section (Feature 3) ──────────────────────────────────────────────
function ThreadSection({ messageId, currentId, navigate }: { messageId: string; currentId: string; navigate: (to: string) => void }) {
const { data: thread = [] } = useQuery({
queryKey: ['thread', messageId],
queryFn: () => messagesApi.getThread(messageId),
})
// Mostra solo se ci sono piu' di 1 messaggio nel thread
if (thread.length <= 1) return null
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Conversazione ({thread.length} messaggi)
</h3>
<div className="space-y-1.5 rounded-lg border bg-background p-3">
{thread.map((msg) => {
const isCurrent = msg.id === currentId
return (
<button
key={msg.id}
type="button"
onClick={() => !isCurrent && navigate(`/messages/${msg.id}`)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors ${
isCurrent
? 'bg-primary/10 border border-primary/30 cursor-default'
: 'hover:bg-muted/60 cursor-pointer'
}`}
>
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${msg.direction === 'inbound' ? 'bg-blue-500' : 'bg-green-500'}`} />
<div className="flex-1 min-w-0">
<p className={`text-sm truncate ${isCurrent ? 'font-semibold' : 'font-medium'}`}>
{msg.subject || '(nessun oggetto)'}
</p>
<p className="text-xs text-muted-foreground">
{msg.direction === 'inbound' ? msg.from_address : `A: ${(msg.to_addresses || []).join(', ')}`}
{' · '}
{formatDate(msg.received_at || msg.sent_at || msg.created_at)}
</p>
</div>
{isCurrent && <span className="text-xs text-primary flex-shrink-0">Questo</span>}
</button>
)
})}
</div>
</div>
)
}
// ─── Attachment preview modal ─────────────────────────────────────────────────
interface AttachmentPreviewProps {
messageId: string
attachmentId: string
filename: string
contentType: string
onClose: () => void
}
function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType, onClose }: AttachmentPreviewProps) {
const { data, isLoading } = useQuery({
queryKey: ['attachment-preview', messageId, attachmentId],
queryFn: () => messagesApi.getAttachmentPreviewUrl(messageId, attachmentId),
})
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="relative bg-background rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b">
<span className="font-medium truncate">{filename}</span>
<button onClick={onClose} className="p-1 rounded hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-auto p-2 min-h-[400px] flex items-center justify-center">
{isLoading && (
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
)}
{!isLoading && data && (
<>
{data.previewable && data.url ? (
<>
{contentType.startsWith('image/') ? (
<img src={data.url} alt={filename} className="max-w-full max-h-full object-contain" />
) : contentType === 'application/pdf' ? (
<iframe src={data.url} className="w-full h-[70vh]" title={filename} />
) : null}
</>
) : (
<div className="text-center text-muted-foreground">
<Eye className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>Anteprima non disponibile per questo tipo di file.</p>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function MessageDetailPage() {
const { id } = useParams<{ id: string }>()
@@ -36,6 +151,15 @@ export function MessageDetailPage() {
const [showTagSelector, setShowTagSelector] = useState(false)
const [isDownloadingPackage, setIsDownloadingPackage] = useState(false)
const [isPrinting, setIsPrinting] = useState(false)
// Feature 4: Deadline
const [showDeadlineForm, setShowDeadlineForm] = useState(false)
const [deadlineDate, setDeadlineDate] = useState('')
const [deadlineNote, setDeadlineNote] = useState('')
// Feature 7: Preview allegati
const [previewAtt, setPreviewAtt] = useState<{ id: string; filename: string; contentType: string } | null>(null)
// Carica messaggio
const {
@@ -362,6 +486,52 @@ export function MessageDetailPage() {
)}
Scarica
</Button>
{/* Stampa (Feature 8) */}
<Button
variant="outline"
size="sm"
isLoading={isPrinting}
onClick={async () => {
if (!message) return
setIsPrinting(true)
try {
const response = await apiClient.get(`/messages/${message.id}/print`, { responseType: 'blob' })
const html = await response.data.text()
const w = window.open('', '_blank')
if (w) {
w.document.write(html)
w.document.close()
setTimeout(() => w.print(), 500)
}
} catch (e) {
toast.error('Errore apertura stampa')
} finally {
setIsPrinting(false)
}
}}
title="Stampa / Salva come PDF"
>
<Printer className="h-4 w-4 mr-1" />
Stampa
</Button>
{/* Scadenza (Feature 4) */}
<Button
variant="outline"
size="sm"
onClick={() => {
const dl = (message as any).deadline_at
setDeadlineDate(dl ? dl.substring(0, 16) : '')
setDeadlineNote((message as any).deadline_note ?? '')
setShowDeadlineForm(true)
}}
title="Imposta scadenza"
className={(message as any).deadline_at ? 'border-amber-400 text-amber-600' : ''}
>
<Calendar className="h-4 w-4 mr-1" />
{(message as any).deadline_at ? 'Scadenza' : 'Scadenza'}
</Button>
</div>
</div>
@@ -522,25 +692,39 @@ export function MessageDetailPage() {
Allegati ({attachments.length})
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{attachments.map((att) => (
<button
key={att.id}
type="button"
onClick={() => handleDownloadAttachment(att)}
className="flex items-center gap-3 rounded-lg border bg-background p-3 hover:bg-muted/50 transition-colors group text-left w-full"
>
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Paperclip className="h-5 w-5 text-primary" />
{attachments.map((att) => {
const isPreviewable = att.content_type?.startsWith('image/') || att.content_type === 'application/pdf'
return (
<div key={att.id} className="flex items-center gap-2 rounded-lg border bg-background p-3">
<button
type="button"
onClick={() => handleDownloadAttachment(att)}
className="flex items-center gap-3 flex-1 min-w-0 text-left hover:opacity-80"
>
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Paperclip className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{att.filename}</p>
<p className="text-xs text-muted-foreground">
{att.content_type || 'Tipo sconosciuto'} • {formatBytes(att.size_bytes)}
</p>
</div>
<Download className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</button>
{isPreviewable && (
<button
type="button"
onClick={() => setPreviewAtt({ id: att.id, filename: att.filename, contentType: att.content_type || '' })}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-primary flex-shrink-0"
title="Anteprima"
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{att.filename}</p>
<p className="text-xs text-muted-foreground">
{att.content_type || 'Tipo sconosciuto'} • {formatBytes(att.size_bytes)}
</p>
</div>
<Download className="h-4 w-4 text-muted-foreground group-hover:text-foreground flex-shrink-0" />
</button>
))}
)
})}
</div>
</div>
)}
@@ -558,6 +742,41 @@ export function MessageDetailPage() {
</div>
)}
{/* Scadenza (Feature 4) */}
{(message as any).deadline_at && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-amber-700">
<Calendar className="h-4 w-4" />
<span>
<strong>Scadenza:</strong> {formatDate((message as any).deadline_at)}
</span>
{(message as any).deadline_note && (
<span className="text-amber-600 italic">— {(message as any).deadline_note}</span>
)}
</div>
<button
onClick={() => {
setDeadlineDate('')
setDeadlineNote('')
deadlinesApi.setDeadline(message.id, { deadline_at: null }).then(() => {
queryClient.invalidateQueries({ queryKey: ['message', id] })
toast.success('Scadenza rimossa')
})
}}
className="text-xs text-amber-600 hover:text-amber-800"
>
Rimuovi
</button>
</div>
</div>
)}
{/* Thread (Feature 3) */}
{message.pec_type === 'posta_certificata' && (
<ThreadSection messageId={message.id} currentId={message.id} navigate={navigate} />
)}
{/* 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">
@@ -575,6 +794,63 @@ export function MessageDetailPage() {
</div>
</div>
{/* Modali */}
{/* Deadline form (Feature 4) */}
{showDeadlineForm && (
<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-96 space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Calendar className="h-5 w-5 text-amber-500" />
Imposta scadenza
</h3>
<div className="space-y-2">
<Label>Data e ora scadenza</Label>
<Input
type="datetime-local"
value={deadlineDate}
onChange={e => setDeadlineDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Nota (opzionale)</Label>
<Input
value={deadlineNote}
onChange={e => setDeadlineNote(e.target.value)}
placeholder="Es. Termine per impugnare, entro 30 giorni..."
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<Button variant="outline" onClick={() => setShowDeadlineForm(false)}>Annulla</Button>
<Button
onClick={async () => {
await deadlinesApi.setDeadline(message.id, {
deadline_at: deadlineDate ? new Date(deadlineDate).toISOString() : null,
deadline_note: deadlineNote || null,
})
queryClient.invalidateQueries({ queryKey: ['message', id] })
toast.success(deadlineDate ? 'Scadenza impostata' : 'Scadenza rimossa')
setShowDeadlineForm(false)
}}
>
Salva
</Button>
</div>
</div>
</div>
)}
{/* Attachment preview (Feature 7) */}
{previewAtt && (
<AttachmentPreviewModal
messageId={message.id}
attachmentId={previewAtt.id}
filename={previewAtt.filename}
contentType={previewAtt.contentType}
onClose={() => setPreviewAtt(null)}
/>
)}
{/* Dialog gestione tag */}
{showTagSelector && (
<TagSelector
@@ -0,0 +1,394 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2, CheckCircle, XCircle, Settings2, ChevronDown, ChevronUp } 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 { routingRulesApi, type RoutingRuleResponse, type RoutingRuleCreate, type ConditionField, type ConditionOperator, type ActionType } from '@/api/routing_rules.api'
import { labelsApi } from '@/api/labels.api'
import { getErrorMessage } from '@/api/client'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
const FIELD_LABELS: Record<ConditionField, string> = {
from_address: 'Mittente',
to_address: 'Destinatario',
subject: 'Oggetto',
mailbox_id: 'ID Casella',
pec_type: 'Tipo PEC',
}
const OPERATOR_LABELS: Record<ConditionOperator, string> = {
contains: 'contiene',
not_contains: 'non contiene',
equals: 'uguale a',
starts_with: 'inizia per',
ends_with: 'finisce per',
regex: 'regex',
}
const ACTION_LABELS: Record<ActionType, string> = {
apply_label: 'Applica etichetta',
assign_vbox: 'Assegna Virtual Box',
mark_read: 'Segna come letto',
mark_starred: 'Aggiungi ai preferiti',
notify_webhook: 'Notifica webhook',
}
interface Condition {
field: ConditionField
operator: ConditionOperator
value: string
}
interface Action {
action_type: ActionType
action_value: string
}
export function RoutingRulesPage() {
const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<RoutingRuleResponse | null>(null)
const [expandedRules, setExpandedRules] = useState<Set<string>>(new Set())
// Form state
const [formName, setFormName] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formPriority, setFormPriority] = useState('100')
const [formStopProcessing, setFormStopProcessing] = useState(true)
const [formConditions, setFormConditions] = useState<Condition[]>([
{ field: 'from_address', operator: 'contains', value: '' }
])
const [formActions, setFormActions] = useState<Action[]>([
{ action_type: 'mark_read', action_value: '' }
])
const { data, isLoading } = useQuery({
queryKey: ['routing-rules'],
queryFn: () => routingRulesApi.list(),
})
const { data: labelsData } = useQuery({
queryKey: ['labels'],
queryFn: () => labelsApi.list(),
})
const labels = labelsData ?? []
const createMutation = useMutation({
mutationFn: (d: RoutingRuleCreate) => routingRulesApi.create(d),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
toast.success('Regola creata')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: RoutingRuleCreate }) =>
routingRulesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
toast.success('Regola aggiornata')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => routingRulesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['routing-rules'] })
toast.success('Regola eliminata')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const toggleMutation = useMutation({
mutationFn: (id: string) => routingRulesApi.toggle(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-rules'] }),
onError: (e) => toast.error(getErrorMessage(e)),
})
const openCreate = () => {
setEditing(null)
setFormName('')
setFormDescription('')
setFormPriority('100')
setFormStopProcessing(true)
setFormConditions([{ field: 'from_address', operator: 'contains', value: '' }])
setFormActions([{ action_type: 'mark_read', action_value: '' }])
setShowForm(true)
}
const openEdit = (r: RoutingRuleResponse) => {
setEditing(r)
setFormName(r.name)
setFormDescription(r.description ?? '')
setFormPriority(String(r.priority))
setFormStopProcessing(r.stop_processing)
setFormConditions(r.conditions.map(c => ({ field: c.field as ConditionField, operator: c.operator as ConditionOperator, value: c.value })))
setFormActions(r.actions.map(a => ({ action_type: a.action_type as ActionType, action_value: a.action_value ?? '' })))
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditing(null)
}
const handleSubmit = () => {
if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
if (formConditions.some(c => !c.value.trim())) return toast.error('Tutte le condizioni devono avere un valore')
const payload: RoutingRuleCreate = {
name: formName.trim(),
description: formDescription.trim() || null,
priority: parseInt(formPriority) || 100,
stop_processing: formStopProcessing,
is_active: true,
conditions: formConditions.map(c => ({ field: c.field, operator: c.operator, value: c.value.trim() })),
actions: formActions.map(a => ({ action_type: a.action_type, action_value: a.action_value.trim() || null })),
}
if (editing) {
updateMutation.mutate({ id: editing.id, data: payload })
} else {
createMutation.mutate(payload)
}
}
const items = data?.items ?? []
const toggleExpand = (id: string) => {
setExpandedRules(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div className="flex flex-col h-full">
<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">
<Settings2 className="h-5 w-5 text-primary" />
Regole di smistamento
</h1>
<p className="text-sm text-muted-foreground">
Applica automaticamente etichette e azioni ai messaggi in arrivo
</p>
</div>
<Button onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
Nuova regola
</Button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-3">
{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>
) : items.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Settings2 className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">Nessuna regola configurata</p>
<p className="text-sm mt-1">Le regole vengono valutate in ordine di priorita' su ogni messaggio inbound.</p>
</div>
) : (
items.map((rule) => (
<div key={rule.id} className={cn('rounded-lg border bg-card', !rule.is_active && 'opacity-60')}>
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{rule.name}</span>
<span className="text-xs text-muted-foreground">P:{rule.priority}</span>
<span className={cn('text-xs px-2 py-0.5 rounded-full', rule.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600')}>
{rule.is_active ? 'Attiva' : 'Inattiva'}
</span>
{rule.stop_processing && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700">Stop</span>
)}
</div>
{rule.description && <p className="text-xs text-muted-foreground mt-0.5">{rule.description}</p>}
<p className="text-xs text-muted-foreground mt-0.5">
{rule.conditions.length} condizioni / {rule.actions.length} azioni
</p>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(rule)} title="Modifica">
<Settings2 className="h-4 w-4" />
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8"
onClick={() => toggleMutation.mutate(rule.id)}
title={rule.is_active ? 'Disattiva' : 'Attiva'}
>
{rule.is_active
? <XCircle className="h-4 w-4 text-amber-500" />
: <CheckCircle className="h-4 w-4 text-green-500" />
}
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8"
onClick={() => { if (confirm(`Eliminare la regola "${rule.name}"?`)) deleteMutation.mutate(rule.id) }}
title="Elimina"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toggleExpand(rule.id)}>
{expandedRules.has(rule.id) ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
</div>
{expandedRules.has(rule.id) && (
<div className="border-t px-4 py-3 space-y-3">
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase mb-2">Condizioni (AND)</p>
{rule.conditions.map((c, i) => (
<div key={i} className="flex items-center gap-2 text-xs bg-muted/40 rounded px-3 py-1.5 mb-1">
<span className="font-medium">{FIELD_LABELS[c.field as ConditionField] ?? c.field}</span>
<span className="text-muted-foreground">{OPERATOR_LABELS[c.operator as ConditionOperator] ?? c.operator}</span>
<span className="font-mono bg-background border rounded px-1.5 py-0.5">{c.value}</span>
</div>
))}
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase mb-2">Azioni</p>
{rule.actions.map((a, i) => (
<div key={i} className="flex items-center gap-2 text-xs bg-blue-50 rounded px-3 py-1.5 mb-1">
<span className="font-medium text-blue-700">{ACTION_LABELS[a.action_type as ActionType] ?? a.action_type}</span>
{a.action_value && <span className="font-mono text-blue-600">{a.action_value}</span>}
</div>
))}
</div>
</div>
)}
</div>
))
)}
</div>
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? 'Modifica regola' : 'Nuova regola di smistamento'}</DialogTitle>
</DialogHeader>
<div className="space-y-5 mt-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2 col-span-2">
<Label>Nome *</Label>
<Input value={formName} onChange={e => setFormName(e.target.value)} placeholder="Es. Multe comune Roma" />
</div>
<div className="space-y-2">
<Label>Priorita' (1=alta, 999=bassa)</Label>
<Input type="number" value={formPriority} onChange={e => setFormPriority(e.target.value)} min={1} max={999} />
</div>
<div className="space-y-2">
<Label>Descrizione</Label>
<Input value={formDescription} onChange={e => setFormDescription(e.target.value)} placeholder="Descrizione opzionale" />
</div>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={formStopProcessing} onChange={e => setFormStopProcessing(e.target.checked)} className="rounded" />
<span className="text-sm">Interrompi elaborazione dopo questa regola (stop processing)</span>
</label>
{/* Condizioni */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Condizioni (tutte devono essere soddisfatte - AND)</Label>
<Button type="button" variant="outline" size="sm" onClick={() => setFormConditions(prev => [...prev, { field: 'from_address', operator: 'contains', value: '' }])}>
<Plus className="h-3.5 w-3.5 mr-1" />Aggiungi
</Button>
</div>
{formConditions.map((cond, i) => (
<div key={i} className="flex items-center gap-2 p-3 border rounded-lg bg-muted/20">
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.field}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, field: e.target.value as ConditionField } : c))}
>
{(Object.entries(FIELD_LABELS) as [ConditionField, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={cond.operator}
onChange={e => setFormConditions(prev => prev.map((c, idx) => idx === i ? { ...c, operator: e.target.value as ConditionOperator } : c))}
>
{(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..."
/>
<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>
</div>
))}
</div>
{/* Azioni */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Azioni da eseguire</Label>
<Button type="button" variant="outline" size="sm" onClick={() => setFormActions(prev => [...prev, { action_type: 'mark_read', action_value: '' }])}>
<Plus className="h-3.5 w-3.5 mr-1" />Aggiungi
</Button>
</div>
{formActions.map((action, i) => (
<div key={i} className="flex items-center gap-2 p-3 border rounded-lg bg-blue-50">
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm flex-1"
value={action.action_type}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_type: e.target.value as ActionType, action_value: '' } : a))}
>
{(Object.entries(ACTION_LABELS) as [ActionType, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
{(action.action_type === 'apply_label') && (
<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 etichetta --</option>
{labels.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
)}
{(action.action_type === 'notify_webhook') && (
<Input
className="flex-1"
value={action.action_value}
onChange={e => setFormActions(prev => prev.map((a, idx) => idx === i ? { ...a, action_value: e.target.value } : a))}
placeholder="https://..."
/>
)}
<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>
</div>
))}
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={closeForm}>Annulla</Button>
<Button onClick={handleSubmit} isLoading={createMutation.isPending || updateMutation.isPending}>
{editing ? 'Salva modifiche' : 'Crea regola'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,228 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Pencil, Trash2, FileText, Search } 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 { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor'
import { templatesApi, type TemplateResponse, type TemplateCreate } from '@/api/templates.api'
import { getErrorMessage } from '@/api/client'
import { formatDate } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
export function TemplatesPage() {
const queryClient = useQueryClient()
const { isAdmin } = useAuth()
const [q, setQ] = useState('')
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<TemplateResponse | null>(null)
// Form state
const [formName, setFormName] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formSubject, setFormSubject] = useState('')
const [formBody, setFormBody] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['templates', q],
queryFn: () => templatesApi.list(q || undefined),
})
const createMutation = useMutation({
mutationFn: (data: TemplateCreate) => templatesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates'] })
toast.success('Template creato')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: TemplateCreate }) =>
templatesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates'] })
toast.success('Template aggiornato')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => templatesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['templates'] })
toast.success('Template eliminato')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const openCreate = () => {
setEditing(null)
setFormName('')
setFormDescription('')
setFormSubject('')
setFormBody('')
setShowForm(true)
}
const openEdit = (t: TemplateResponse) => {
setEditing(t)
setFormName(t.name)
setFormDescription(t.description ?? '')
setFormSubject(t.subject)
setFormBody(t.body_html ?? t.body_text ?? '')
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditing(null)
}
const handleSubmit = () => {
if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
const payload: TemplateCreate = {
name: formName.trim(),
description: formDescription.trim() || null,
subject: formSubject.trim(),
body_html: formBody || null,
body_text: null,
}
if (editing) {
updateMutation.mutate({ id: editing.id, data: payload })
} else {
createMutation.mutate(payload)
}
}
const items = data?.items ?? []
return (
<div className="flex flex-col h-full">
<div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">Template messaggi</h1>
<p className="text-sm text-muted-foreground">
Template riutilizzabili per la composizione PEC
</p>
</div>
{isAdmin && (
<Button onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
Nuovo template
</Button>
)}
</div>
<div className="p-6 space-y-4 flex-1 overflow-y-auto">
{/* Ricerca */}
<div className="relative w-full max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cerca per nome..."
value={q}
onChange={(e) => setQ(e.target.value)}
className="pl-9"
/>
</div>
{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>
) : items.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">Nessun template trovato</p>
<p className="text-sm mt-1">
{isAdmin ? 'Crea il tuo primo template con il pulsante in alto.' : 'Nessun template disponibile.'}
</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((t) => (
<div key={t.id} className="rounded-lg border bg-card p-4 space-y-2 hover:shadow-sm transition-shadow">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-semibold truncate">{t.name}</h3>
{t.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{t.description}</p>
)}
</div>
{isAdmin && (
<div className="flex gap-1 flex-shrink-0">
<Button variant="ghost" size="icon" onClick={() => openEdit(t)} className="h-8 w-8">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => {
if (confirm(`Eliminare il template "${t.name}"?`)) {
deleteMutation.mutate(t.id)
}
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
{t.subject && (
<p className="text-sm font-medium text-foreground/80 truncate">
Oggetto: {t.subject}
</p>
)}
<p className="text-xs text-muted-foreground">
Aggiornato: {formatDate(t.updated_at)}
</p>
</div>
))}
</div>
)}
</div>
{/* Dialog form */}
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? 'Modifica template' : 'Nuovo template'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Nome *</Label>
<Input value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="Es. Risposta a ricorso" />
</div>
<div className="space-y-2">
<Label>Descrizione (opzionale)</Label>
<Input value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Breve descrizione del template" />
</div>
<div className="space-y-2">
<Label>Oggetto predefinito</Label>
<Input value={formSubject} onChange={(e) => setFormSubject(e.target.value)} placeholder="Oggetto del messaggio PEC" />
</div>
<div className="space-y-2">
<Label>Corpo del messaggio</Label>
<div className="min-h-[200px] border rounded-md overflow-hidden">
<RichTextEditor value={formBody} onChange={setFormBody} placeholder="Testo del template..." />
</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={closeForm}>Annulla</Button>
<Button onClick={handleSubmit} isLoading={createMutation.isPending || updateMutation.isPending}>
{editing ? 'Salva modifiche' : 'Crea template'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}