/** * SettingsPage - impostazioni profilo utente e configurazione tenant (admin). * * Sezioni: * - Informazioni profilo (nome visualizzato, email, ruolo) * - Modifica nome * - Cambio password * - [Solo admin] Archiviazione Sostitutiva - toggle mock/produzione + credenziali conservatore * - [Solo admin] Indicizzazione Full-Text - statistiche, reindex, monitoraggio job */ import { useEffect, useRef, useState } from 'react' import { Settings, User, Lock, Save, Archive, ChevronDown, ChevronUp, CheckCircle, AlertTriangle, Eye, EyeOff, FlaskConical, Zap, Search, RefreshCw, XCircle, Clock, FileText, BarChart3, AlertCircle, CheckCircle2, Loader2, } from 'lucide-react' import { useAuth } from '@/hooks/useAuth' import { useAuthStore } from '@/store/auth.store' import { usersApi } from '@/api/users.api' import { settingsApi, type TenantSettingsResponse, type ArchivalMode, type IndexingStats, type IndexingJobStatus, type ReindexMode, } from '@/api/settings.api' import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { Label } from '@/components/ui/Label' import { Card } from '@/components/ui/Card' import toast from 'react-hot-toast' // ─── Utility ───────────────────────────────────────────────────────────────── function roleLabel(role: string): string { switch (role) { case 'super_admin': return 'Super Amministratore' case 'admin': return 'Amministratore' case 'operator': return 'Operatore' default: return role } } function formatElapsed(seconds: number | null): string { if (seconds === null || seconds < 0) return '-' if (seconds < 60) return `${seconds}s` if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s` const h = Math.floor(seconds / 3600) const m = Math.floor((seconds % 3600) / 60) return `${h}h ${m}m` } function formatDatetime(iso: string | null): string { if (!iso) return '-' return new Date(iso).toLocaleString('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', }) } function modeLabel(mode: ReindexMode | null): string { if (!mode) return '-' return mode === 'full' ? 'Totale' : 'Differenziale' } function rescanModeLabel(mode: string | null): string { if (!mode) return '-' return mode === 'force' ? 'Forzata (tutti)' : 'Differenziale' } // ─── Badge modalita' archiviazione ─────────────────────────────────────────── function ArchivalModeBadge({ mode }: { mode: ArchivalMode }) { if (mode === 'production') { return ( Produzione ) } return ( Mock (simulato) ) } // ─── Badge stato job ────────────────────────────────────────────────────────── function JobStatusBadge({ status, isStale }: { status: string; isStale: boolean }) { if (status === 'running') { return ( {isStale ? : } {isStale ? 'Bloccato?' : 'In esecuzione'} ) } if (status === 'completed') { return ( Completato ) } if (status === 'failed') { return ( Errore ) } if (status === 'cancelled') { return ( Annullato ) } // idle return ( Inattivo ) } // ─── Barra di avanzamento ──────────────────────────────────────────────────── function ProgressBar({ value, max = 100, color = 'blue', animated = false, }: { value: number max?: number color?: 'blue' | 'green' | 'amber' | 'red' | 'gray' animated?: boolean }) { const pct = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0 const colorClass = { blue: 'bg-blue-500', green: 'bg-green-500', amber: 'bg-amber-500', red: 'bg-red-500', gray: 'bg-gray-400', }[color] return (
) } // ─── Sezione Indicizzazione ─────────────────────────────────────────────────── interface IndexingSectionProps { isSuperAdmin: boolean } function IndexingSection({ isSuperAdmin }: IndexingSectionProps) { const [expanded, setExpanded] = useState(false) const [stats, setStats] = useState(null) const [jobStatus, setJobStatus] = useState(null) const [rescanStatus, setRescanStatus] = useState(null) const [loading, setLoading] = useState(false) const [actionLoading, setActionLoading] = useState(false) const [rescanActionLoading, setRescanActionLoading] = useState(false) const [showCancelConfirm, setShowCancelConfirm] = useState(false) const [showFullReindexConfirm, setShowFullReindexConfirm] = useState(false) const [showRescanCancelConfirm, setShowRescanCancelConfirm] = useState(false) const [showForceRescanConfirm, setShowForceRescanConfirm] = useState(false) // Polling reindex e rescan separati const pollingRef = useRef | null>(null) const rescanPollingRef = useRef | null>(null) const isRunning = jobStatus?.status === 'running' const isRescanRunning = rescanStatus?.status === 'running' const anyJobRunning = isRunning || isRescanRunning // Carica dati iniziali quando si espande la sezione const loadData = async () => { setLoading(true) try { const [s, j, r] = await Promise.all([ settingsApi.getIndexingStats(), settingsApi.getIndexingStatus(), settingsApi.getRescanStatus(), ]) setStats(s) setJobStatus(j) setRescanStatus(r) } catch { toast.error('Errore durante il caricamento delle statistiche di indicizzazione') } finally { setLoading(false) } } // Polling reindex const refreshStatus = async () => { try { const j = await settingsApi.getIndexingStatus() setJobStatus(j) if (j.status !== 'running' && isRunning) { const s = await settingsApi.getIndexingStats() setStats(s) } } catch { // Silenzioso } } // Polling rescan const refreshRescanStatus = async () => { try { const r = await settingsApi.getRescanStatus() setRescanStatus(r) if (r.status !== 'running' && isRescanRunning) { const s = await settingsApi.getIndexingStats() setStats(s) } } catch { // Silenzioso } } useEffect(() => { if (expanded) { loadData() } }, [expanded]) // Polling reindex useEffect(() => { if (isRunning) { pollingRef.current = setInterval(refreshStatus, 3000) } else { if (pollingRef.current) { clearInterval(pollingRef.current) pollingRef.current = null } } return () => { if (pollingRef.current) clearInterval(pollingRef.current) } }, [isRunning]) // Polling rescan useEffect(() => { if (isRescanRunning) { rescanPollingRef.current = setInterval(refreshRescanStatus, 3000) } else { if (rescanPollingRef.current) { clearInterval(rescanPollingRef.current) rescanPollingRef.current = null } } return () => { if (rescanPollingRef.current) clearInterval(rescanPollingRef.current) } }, [isRescanRunning]) const handleStartReindex = async (mode: ReindexMode) => { setActionLoading(true) setShowFullReindexConfirm(false) try { const j = await settingsApi.startReindex(mode) setJobStatus(j) toast.success( mode === 'full' ? 'Reindex totale avviato in background' : 'Reindex differenziale avviato in background' ) } catch (err: unknown) { const msg = (err as { response?: { data?: { detail?: string } } }) ?.response?.data?.detail toast.error(msg ?? 'Errore durante l\'avvio del reindex') } finally { setActionLoading(false) } } const handleCancel = async () => { setActionLoading(true) setShowCancelConfirm(false) try { const j = await settingsApi.cancelReindex() setJobStatus(j) toast.success('Segnale di annullamento inviato. Il job si fermera\' al prossimo batch.') } catch (err: unknown) { const msg = (err as { response?: { data?: { detail?: string } } }) ?.response?.data?.detail toast.error(msg ?? 'Errore durante l\'annullamento') } finally { setActionLoading(false) } } const handleStartRescan = async (force: boolean) => { setRescanActionLoading(true) setShowForceRescanConfirm(false) try { const r = await settingsApi.startRescan(force) setRescanStatus(r) toast.success( force ? 'Riscansione forzata avviata in background' : 'Riscansione allegati avviata in background' ) } catch (err: unknown) { const msg = (err as { response?: { data?: { detail?: string } } }) ?.response?.data?.detail toast.error(msg ?? 'Errore durante l\'avvio della scansione allegati') } finally { setRescanActionLoading(false) } } const handleCancelRescan = async () => { setRescanActionLoading(true) setShowRescanCancelConfirm(false) try { const r = await settingsApi.cancelRescan() setRescanStatus(r) toast.success('Segnale di annullamento inviato. La scansione si fermera\' al prossimo batch.') } catch (err: unknown) { const msg = (err as { response?: { data?: { detail?: string } } }) ?.response?.data?.detail toast.error(msg ?? 'Errore durante l\'annullamento della scansione') } finally { setRescanActionLoading(false) } } // Colore della barra di copertura const coverageColor = (pct: number) => { if (pct >= 90) return 'green' as const if (pct >= 60) return 'amber' as const return 'red' as const } return ( {/* Header collassabile */} {expanded && (
{loading ? (
Caricamento statistiche...
) : ( <> {/* ── Spiegazione ── */}

Come funziona l'indicizzazione

Ogni messaggio PEC contiene un vettore di ricerca full-text (search_vector) generato automaticamente da oggetto, mittente, destinatari e corpo. Il worker indicizza anche il testo estratto dagli allegati PDF e DOCX. Il reindex rigenera questi vettori manualmente, utile dopo migrazioni o in caso di messaggi non indicizzati.

{/* ── Statistiche messaggi ── */} {stats && (

Copertura indicizzazione

{/* Card statistiche messaggi */}

{stats.total_messages.toLocaleString('it-IT')}

Messaggi totali

{stats.indexed_messages.toLocaleString('it-IT')}

Indicizzati

0 ? 'bg-amber-50 border-amber-200' : 'bg-gray-50 border-gray-200' }`}>

0 ? 'text-amber-700' : 'text-gray-500' }`}> {stats.unindexed_messages.toLocaleString('it-IT')}

0 ? 'text-amber-600' : 'text-gray-500' }`}> Non indicizzati

{/* Barra copertura messaggi */}
Copertura messaggi = 90 ? 'text-green-700' : stats.coverage_pct >= 60 ? 'text-amber-700' : 'text-red-700' }`}> {stats.coverage_pct}%
{stats.unindexed_messages > 0 && (

{stats.unindexed_messages.toLocaleString('it-IT')} messaggi non hanno ancora il vettore di ricerca. Usa il reindex differenziale per indicizzarli.

)}
{/* Sezione allegati */} {stats.attachments_total > 0 && (
Allegati PDF/DOCX con testo estratto
{rescanStatus && ( )}
{stats.attachments_extracted.toLocaleString('it-IT')} / {stats.attachments_total.toLocaleString('it-IT')} allegati {stats.attachments_pct}%
{stats.attachments_pct < 100 && (

{(stats.attachments_total - stats.attachments_extracted).toLocaleString('it-IT')} allegati non hanno ancora il testo estratto. Usa Riscansiona allegati per elaborarli.

)}

Il testo degli allegati viene estratto automaticamente dal worker durante la sincronizzazione IMAP. Il reindex include il testo gia' estratto nei vettori di ricerca.

)}
)}
{/* ── Stato job in corso ── */} {jobStatus && (

Stato indicizzazione

{isRunning && ( aggiornamento automatico ogni 3s )}
{/* Riga stato */}
{jobStatus.mode && ( Modalita': {modeLabel(jobStatus.mode)} )}
{isRunning && ( {jobStatus.processed.toLocaleString('it-IT')} / {jobStatus.total.toLocaleString('it-IT')} )}
{/* Barra progresso (quando running o completed) */} {(jobStatus.status === 'running' || jobStatus.status === 'completed') && jobStatus.total > 0 && (
{jobStatus.progress_pct}% completato {jobStatus.elapsed_seconds !== null && ( Durata: {formatElapsed(jobStatus.elapsed_seconds)} )}
)} {/* Metadati job */} {jobStatus.status !== 'idle' && (
{jobStatus.started_at && (
Avviato: {formatDatetime(jobStatus.started_at)}
)} {jobStatus.finished_at && (
Terminato: {formatDatetime(jobStatus.finished_at)}
)} {jobStatus.started_by && (
Avviato da: {jobStatus.started_by}
)}
)} {/* Alert stale */} {jobStatus.is_stale && (

Job potenzialmente bloccato

Il job e' in esecuzione da {formatElapsed(jobStatus.elapsed_seconds)} e potrebbe essere bloccato. Usa il pulsante "Termina indicizzazione" per cancellarlo e riavviarlo.

)} {/* Errore */} {jobStatus.status === 'failed' && jobStatus.error && (
{jobStatus.error}
)} {/* Info idle */} {jobStatus.status === 'idle' && (

Nessun job di reindex in corso. Usa i pulsanti in basso per avviarne uno.

)}
)} {/* ── Stato scansione allegati ── */} {rescanStatus && (

Stato scansione allegati

{isRescanRunning && ( aggiornamento automatico ogni 3s )}
{rescanStatus.mode && ( Modalita': {rescanModeLabel(rescanStatus.mode)} )}
{isRescanRunning && ( {rescanStatus.processed.toLocaleString('it-IT')} / {rescanStatus.total.toLocaleString('it-IT')} allegati )}
{(rescanStatus.status === 'running' || rescanStatus.status === 'completed') && rescanStatus.total > 0 && (
{rescanStatus.progress_pct}% completato {rescanStatus.elapsed_seconds !== null && ( Durata: {formatElapsed(rescanStatus.elapsed_seconds)} )}
)} {rescanStatus.status !== 'idle' && (
{rescanStatus.started_at && (
Avviato: {formatDatetime(rescanStatus.started_at)}
)} {rescanStatus.finished_at && (
Terminato: {formatDatetime(rescanStatus.finished_at)}
)} {rescanStatus.started_by && (
Avviato da: {rescanStatus.started_by}
)}
)} {rescanStatus.status === 'failed' && rescanStatus.error && (
{rescanStatus.error}
)} {rescanStatus.status === 'idle' && (

Nessuna scansione allegati in corso. Usa il pulsante "Riscansiona allegati" per avviarne una.

)}
)} {/* ── Dialogs conferma ── */} {showCancelConfirm && (

Conferma annullamento reindex

Il job si fermera' alla fine del batch corrente. I messaggi gia' indicizzati rimarranno indicizzati.

·
)} {showFullReindexConfirm && (

Reindex totale

Verra' riscritto il vettore di ricerca per tutti i{' '} {stats?.total_messages.toLocaleString('it-IT')} messaggi. La ricerca rimane disponibile durante il processo.

·
)} {showForceRescanConfirm && (

Riscansione forzata allegati

Il testo verra' ri-estratto da tutti gli{' '} {stats?.attachments_total.toLocaleString('it-IT')} allegati del tenant, sovrascrivendo quelli gia' presenti. Operazione piu' lunga del differenziale.

·
)} {showRescanCancelConfirm && (

Conferma annullamento scansione

La scansione si fermera' alla fine del batch corrente.

·
)} {/* ── Pulsanti reindex ── */}

Reindex messaggi

{isRunning && ( )}
{/* ── Pulsanti scansione allegati ── */}

Scansione allegati

{isRescanRunning && ( )}
{/* Legenda pulsanti */}

Reindex differenziale – Indicizza solo i messaggi con search_vector NULL. Rapido, ideale per uso routinario.

Reindex totale – Riscrive il vettore di ricerca per tutti i messaggi, includendo il testo degli allegati gia' estratti.

Riscansiona allegati – Scarica da MinIO gli allegati senza testo estratto (PDF, DOCX, ecc.), ne estrae il testo e aggiorna il vettore di ricerca. Differenziale: solo allegati non ancora elaborati.

Riscansione forzata – Come riscansiona, ma ri-estrae il testo da tutti gli allegati (sovrascrive). Utile dopo migrazioni o per correggere estrazioni errate.

)}
)}
) } // ─── Pagina principale ──────────────────────────────────────────────────────── export function SettingsPage() { const { user } = useAuth() const loadUser = useAuthStore((s) => s.loadUser) const isAdmin = user?.role === 'admin' || user?.role === 'super_admin' const isSuperAdmin = user?.role === 'super_admin' /* ── Stato modifica nome ── */ const [fullName, setFullName] = useState(user?.full_name ?? '') const [savingName, setSavingName] = useState(false) /* ── Stato cambio password ── */ const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [savingPwd, setSavingPwd] = useState(false) /* ── Stato impostazioni archiviazione (admin) ── */ const [archivalSettings, setArchivalSettings] = useState(null) const [loadingArchival, setLoadingArchival] = useState(false) const [archivalExpanded, setArchivalExpanded] = useState(false) // Form archiviazione const [archivalMode, setArchivalMode] = useState('mock') const [conservatoreId, setConservatoreId] = useState('') const [conservatoreEndpoint, setConservatoreEndpoint] = useState('') const [conservatoreUsername, setConservatoreUsername] = useState('') const [conservatorePassword, setConservatorePassword] = useState('') const [archivalNotes, setArchivalNotes] = useState('') const [showPassword, setShowPassword] = useState(false) const [savingArchival, setSavingArchival] = useState(false) const [showProductionConfirm, setShowProductionConfirm] = useState(false) /* ── Carica impostazioni archiviazione ── */ useEffect(() => { if (isAdmin) { loadArchivalSettings() } }, [isAdmin]) const loadArchivalSettings = async () => { setLoadingArchival(true) try { const data = await settingsApi.get() setArchivalSettings(data) setArchivalMode(data.archival_mode) setConservatoreId(data.conservatore_id) setConservatoreEndpoint(data.conservatore_endpoint ?? '') setArchivalNotes(data.archival_notes ?? '') } catch { toast.error('Errore durante il caricamento delle impostazioni di archiviazione') } finally { setLoadingArchival(false) } } /* ── Salva nome ── */ const handleSaveName = async () => { if (!user) return if (!fullName.trim()) { toast.error('Il nome non puo\' essere vuoto') return } setSavingName(true) try { await usersApi.update(user.id, { full_name: fullName.trim() }) await loadUser() toast.success('Nome aggiornato con successo') } catch { toast.error('Errore durante il salvataggio del nome') } finally { setSavingName(false) } } /* ── Cambia password ── */ const handleChangePassword = async () => { if (!user) return if (newPassword.length < 8) { toast.error('La password deve essere di almeno 8 caratteri') return } if (newPassword !== confirmPassword) { toast.error('Le password non coincidono') return } setSavingPwd(true) try { await usersApi.resetPassword(user.id, newPassword) setNewPassword('') setConfirmPassword('') toast.success('Password aggiornata con successo') } catch { toast.error('Errore durante il cambio della password') } finally { setSavingPwd(false) } } /* ── Cambio modalita' archiviazione ── */ const handleModeToggle = (newMode: ArchivalMode) => { if (newMode === 'production' && archivalMode === 'mock') { setShowProductionConfirm(true) } else { setArchivalMode(newMode) setShowProductionConfirm(false) } } /* ── Salva impostazioni archiviazione ── */ const handleSaveArchival = async () => { if (archivalMode === 'production' && !conservatoreEndpoint.trim()) { toast.error('La modalita\' produzione richiede un URL endpoint del conservatore') return } setSavingArchival(true) try { const payload: Parameters[0] = { archival_mode: archivalMode, conservatore_id: conservatoreId || undefined, conservatore_endpoint: conservatoreEndpoint || undefined, archival_notes: archivalNotes || undefined, } if (conservatoreUsername) payload.conservatore_username = conservatoreUsername if (conservatorePassword) payload.conservatore_password = conservatorePassword const updated = await settingsApi.update(payload) setArchivalSettings(updated) setArchivalMode(updated.archival_mode) setConservatoreId(updated.conservatore_id) setConservatoreEndpoint(updated.conservatore_endpoint ?? '') setArchivalNotes(updated.archival_notes ?? '') setConservatoreUsername('') setConservatorePassword('') setShowProductionConfirm(false) toast.success( updated.archival_mode === 'production' ? 'Archiviazione attivata in modalita\' PRODUZIONE' : 'Archiviazione impostata in modalita\' mock' ) } catch (err: unknown) { const msg = (err as { response?: { data?: { detail?: string } } }) ?.response?.data?.detail toast.error(msg ?? 'Errore durante il salvataggio delle impostazioni') } finally { setSavingArchival(false) } } if (!user) return null return (
{/* ── Intestazione ── */}

Impostazioni

Gestisci il tuo profilo e le credenziali di accesso

{/* ── Card: Informazioni account ── */}

Informazioni account

Email

{user.email}

Ruolo

{roleLabel(user.role)}


{/* Modifica nome */}
setFullName(e.target.value)} placeholder="Il tuo nome" className="flex-1" />
{/* ── Card: Cambio password ── */}

Cambio password

setNewPassword(e.target.value)} placeholder="Minimo 8 caratteri" />
setConfirmPassword(e.target.value)} placeholder="Ripeti la nuova password" />
{/* ── Card: Archiviazione Sostitutiva (solo admin) ── */} {isAdmin && ( {/* Header collassabile */} {archivalExpanded && (
{loadingArchival ? (

Caricamento impostazioni...

) : ( <> {/* Toggle modalita' */}

In modalita' mock le operazioni di versamento vengono simulate localmente senza inviare dati a sistemi esterni. Attiva la modalita' produzione solo dopo aver configurato l'endpoint e le credenziali del conservatore AgID.

{showProductionConfirm && archivalMode === 'mock' && (

Stai per attivare la modalita' produzione

I versamenti verranno inviati al conservatore AgID reale.

·
)}

setConservatoreId(e.target.value)} placeholder="es. aruba-cons, docuvision, namirial" />
setConservatoreEndpoint(e.target.value)} placeholder="https://conservatore.provider.it/api/v1" />

Vengono salvate cifrate (AES-256-GCM). {archivalSettings?.conservatore_username_configured && ( Username configurata )} {archivalSettings?.conservatore_password_configured && ( Password configurata )}

setConservatoreUsername(e.target.value)} placeholder={archivalSettings?.conservatore_username_configured ? '(credenziale gia\' salvata)' : 'Inserisci username'} autoComplete="off" />
setConservatorePassword(e.target.value)} placeholder={archivalSettings?.conservatore_password_configured ? '(credenziale gia\' salvata)' : 'Inserisci password'} autoComplete="new-password" className="pr-10" />