Files
PecHub/frontend/src/pages/Fascicoli/FascicoloDetailPage.tsx
T

548 lines
23 KiB
TypeScript

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>
)
}