Files
PecHub/frontend/src/pages/Settings/SettingsPage.tsx
T
2026-03-27 13:54:07 +01:00

1447 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
<Zap className="h-3 w-3" />
Produzione
</span>
)
}
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200">
<FlaskConical className="h-3 w-3" />
Mock (simulato)
</span>
)
}
// ─── Badge stato job ──────────────────────────────────────────────────────────
function JobStatusBadge({ status, isStale }: { status: string; isStale: boolean }) {
if (status === 'running') {
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold border ${
isStale
? 'bg-orange-100 text-orange-800 border-orange-200'
: 'bg-blue-100 text-blue-800 border-blue-200'
}`}>
{isStale
? <AlertTriangle className="h-3 w-3" />
: <Loader2 className="h-3 w-3 animate-spin" />
}
{isStale ? 'Bloccato?' : 'In esecuzione'}
</span>
)
}
if (status === 'completed') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
<CheckCircle2 className="h-3 w-3" />
Completato
</span>
)
}
if (status === 'failed') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-red-100 text-red-800 border border-red-200">
<AlertCircle className="h-3 w-3" />
Errore
</span>
)
}
if (status === 'cancelled') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-700 border border-gray-200">
<XCircle className="h-3 w-3" />
Annullato
</span>
)
}
// idle
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-600 border border-gray-200">
<Clock className="h-3 w-3" />
Inattivo
</span>
)
}
// ─── 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 (
<div className="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
<div
className={`h-2 rounded-full transition-all duration-500 ${colorClass} ${animated ? 'animate-pulse' : ''}`}
style={{ width: `${pct}%` }}
/>
</div>
)
}
// ─── Sezione Indicizzazione ───────────────────────────────────────────────────
interface IndexingSectionProps {
isSuperAdmin: boolean
}
function IndexingSection({ isSuperAdmin }: IndexingSectionProps) {
const [expanded, setExpanded] = useState(false)
const [stats, setStats] = useState<IndexingStats | null>(null)
const [jobStatus, setJobStatus] = useState<IndexingJobStatus | null>(null)
const [rescanStatus, setRescanStatus] = useState<IndexingJobStatus | null>(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<ReturnType<typeof setInterval> | null>(null)
const rescanPollingRef = useRef<ReturnType<typeof setInterval> | 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 (
<Card className="p-5">
{/* Header collassabile */}
<button
className="w-full flex items-center justify-between text-left"
onClick={() => setExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Indicizzazione Full-Text
</h2>
{jobStatus && (
<JobStatusBadge status={jobStatus.status} isStale={jobStatus.is_stale} />
)}
</div>
{expanded
? <ChevronUp className="h-4 w-4 text-gray-400" />
: <ChevronDown className="h-4 w-4 text-gray-400" />
}
</button>
{expanded && (
<div className="mt-5 space-y-5">
{loading ? (
<div className="flex items-center justify-center py-8 gap-2 text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Caricamento statistiche...</span>
</div>
) : (
<>
{/* ── Spiegazione ── */}
<div className="rounded-lg bg-blue-50 border border-blue-100 p-3 text-xs text-blue-800">
<p className="font-medium mb-1">Come funziona l'indicizzazione</p>
<p>
Ogni messaggio PEC contiene un vettore di ricerca full-text (<code>search_vector</code>)
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.
</p>
</div>
{/* ── Statistiche messaggi ── */}
{stats && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-gray-400" />
<h3 className="text-xs font-semibold text-gray-600 uppercase tracking-wide">
Copertura indicizzazione
</h3>
<button
className="ml-auto text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1"
onClick={loadData}
disabled={loading}
>
<RefreshCw className="h-3 w-3" />
Aggiorna
</button>
</div>
{/* Card statistiche messaggi */}
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-gray-50 border border-gray-200 p-3 text-center">
<p className="text-2xl font-bold text-gray-800">
{stats.total_messages.toLocaleString('it-IT')}
</p>
<p className="text-xs text-gray-500 mt-0.5">Messaggi totali</p>
</div>
<div className="rounded-lg bg-green-50 border border-green-200 p-3 text-center">
<p className="text-2xl font-bold text-green-700">
{stats.indexed_messages.toLocaleString('it-IT')}
</p>
<p className="text-xs text-green-600 mt-0.5">Indicizzati</p>
</div>
<div className={`rounded-lg border p-3 text-center ${
stats.unindexed_messages > 0
? 'bg-amber-50 border-amber-200'
: 'bg-gray-50 border-gray-200'
}`}>
<p className={`text-2xl font-bold ${
stats.unindexed_messages > 0 ? 'text-amber-700' : 'text-gray-500'
}`}>
{stats.unindexed_messages.toLocaleString('it-IT')}
</p>
<p className={`text-xs mt-0.5 ${
stats.unindexed_messages > 0 ? 'text-amber-600' : 'text-gray-500'
}`}>
Non indicizzati
</p>
</div>
</div>
{/* Barra copertura messaggi */}
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-gray-600">Copertura messaggi</span>
<span className={`font-semibold ${
stats.coverage_pct >= 90 ? 'text-green-700' :
stats.coverage_pct >= 60 ? 'text-amber-700' : 'text-red-700'
}`}>
{stats.coverage_pct}%
</span>
</div>
<ProgressBar
value={stats.indexed_messages}
max={stats.total_messages}
color={coverageColor(stats.coverage_pct)}
/>
{stats.unindexed_messages > 0 && (
<p className="text-xs text-amber-600">
{stats.unindexed_messages.toLocaleString('it-IT')} messaggi non hanno ancora
il vettore di ricerca. Usa il reindex differenziale per indicizzarli.
</p>
)}
</div>
{/* Sezione allegati */}
{stats.attachments_total > 0 && (
<div className="rounded-lg border border-gray-200 p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<FileText className="h-3.5 w-3.5 text-gray-400" />
<span className="text-xs font-medium text-gray-600">
Allegati PDF/DOCX con testo estratto
</span>
</div>
{rescanStatus && (
<JobStatusBadge status={rescanStatus.status} isStale={rescanStatus.is_stale} />
)}
</div>
<div className="flex justify-between text-xs text-gray-500">
<span>
{stats.attachments_extracted.toLocaleString('it-IT')} / {stats.attachments_total.toLocaleString('it-IT')} allegati
</span>
<span className="font-medium">{stats.attachments_pct}%</span>
</div>
<ProgressBar
value={stats.attachments_extracted}
max={stats.attachments_total}
color={coverageColor(stats.attachments_pct)}
/>
{stats.attachments_pct < 100 && (
<p className="text-xs text-amber-600">
{(stats.attachments_total - stats.attachments_extracted).toLocaleString('it-IT')} allegati non hanno ancora il testo estratto.
Usa <strong>Riscansiona allegati</strong> per elaborarli.
</p>
)}
<p className="text-xs text-gray-400">
Il testo degli allegati viene estratto automaticamente dal worker
durante la sincronizzazione IMAP. Il reindex include il testo
gia' estratto nei vettori di ricerca.
</p>
</div>
)}
</div>
)}
<hr className="border-gray-100" />
{/* ── Stato job in corso ── */}
{jobStatus && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<RefreshCw className="h-4 w-4 text-gray-400" />
<h3 className="text-xs font-semibold text-gray-600 uppercase tracking-wide">
Stato indicizzazione
</h3>
{isRunning && (
<span className="ml-1 text-xs text-blue-500 animate-pulse">
aggiornamento automatico ogni 3s
</span>
)}
</div>
<div className={`rounded-lg border p-4 space-y-3 ${
jobStatus.status === 'running' && !jobStatus.is_stale
? 'bg-blue-50 border-blue-200'
: jobStatus.status === 'running' && jobStatus.is_stale
? 'bg-orange-50 border-orange-200'
: jobStatus.status === 'completed'
? 'bg-green-50 border-green-200'
: jobStatus.status === 'failed'
? 'bg-red-50 border-red-200'
: 'bg-gray-50 border-gray-200'
}`}>
{/* Riga stato */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<JobStatusBadge status={jobStatus.status} isStale={jobStatus.is_stale} />
{jobStatus.mode && (
<span className="text-xs text-gray-600">
Modalita': <strong>{modeLabel(jobStatus.mode)}</strong>
</span>
)}
</div>
{isRunning && (
<span className="text-xs font-mono text-blue-700">
{jobStatus.processed.toLocaleString('it-IT')} / {jobStatus.total.toLocaleString('it-IT')}
</span>
)}
</div>
{/* Barra progresso (quando running o completed) */}
{(jobStatus.status === 'running' || jobStatus.status === 'completed') && jobStatus.total > 0 && (
<div className="space-y-1">
<ProgressBar
value={jobStatus.processed}
max={jobStatus.total}
color={jobStatus.status === 'completed' ? 'green' : 'blue'}
animated={jobStatus.status === 'running' && jobStatus.progress_pct < 5}
/>
<div className="flex justify-between text-xs text-gray-500">
<span>{jobStatus.progress_pct}% completato</span>
{jobStatus.elapsed_seconds !== null && (
<span>Durata: {formatElapsed(jobStatus.elapsed_seconds)}</span>
)}
</div>
</div>
)}
{/* Metadati job */}
{jobStatus.status !== 'idle' && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-gray-500">
{jobStatus.started_at && (
<div>
<span className="text-gray-400">Avviato: </span>
<span>{formatDatetime(jobStatus.started_at)}</span>
</div>
)}
{jobStatus.finished_at && (
<div>
<span className="text-gray-400">Terminato: </span>
<span>{formatDatetime(jobStatus.finished_at)}</span>
</div>
)}
{jobStatus.started_by && (
<div className="col-span-2">
<span className="text-gray-400">Avviato da: </span>
<span className="font-medium">{jobStatus.started_by}</span>
</div>
)}
</div>
)}
{/* Alert stale */}
{jobStatus.is_stale && (
<div className="flex items-start gap-2 p-2 rounded bg-orange-100 border border-orange-200">
<AlertTriangle className="h-4 w-4 text-orange-600 flex-shrink-0 mt-0.5" />
<div className="text-xs text-orange-800">
<p className="font-medium">Job potenzialmente bloccato</p>
<p className="mt-0.5">
Il job e' in esecuzione da {formatElapsed(jobStatus.elapsed_seconds)} e
potrebbe essere bloccato. Usa il pulsante "Termina indicizzazione" per
cancellarlo e riavviarlo.
</p>
</div>
</div>
)}
{/* Errore */}
{jobStatus.status === 'failed' && jobStatus.error && (
<div className="p-2 rounded bg-red-50 border border-red-200 text-xs text-red-800 font-mono break-all">
{jobStatus.error}
</div>
)}
{/* Info idle */}
{jobStatus.status === 'idle' && (
<p className="text-xs text-gray-500">
Nessun job di reindex in corso. Usa i pulsanti in basso per avviarne uno.
</p>
)}
</div>
</div>
)}
{/* ── Stato scansione allegati ── */}
{rescanStatus && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-gray-400" />
<h3 className="text-xs font-semibold text-gray-600 uppercase tracking-wide">
Stato scansione allegati
</h3>
{isRescanRunning && (
<span className="ml-1 text-xs text-blue-500 animate-pulse">
aggiornamento automatico ogni 3s
</span>
)}
</div>
<div className={`rounded-lg border p-4 space-y-3 ${
rescanStatus.status === 'running' && !rescanStatus.is_stale
? 'bg-blue-50 border-blue-200'
: rescanStatus.status === 'running' && rescanStatus.is_stale
? 'bg-orange-50 border-orange-200'
: rescanStatus.status === 'completed'
? 'bg-green-50 border-green-200'
: rescanStatus.status === 'failed'
? 'bg-red-50 border-red-200'
: 'bg-gray-50 border-gray-200'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<JobStatusBadge status={rescanStatus.status} isStale={rescanStatus.is_stale} />
{rescanStatus.mode && (
<span className="text-xs text-gray-600">
Modalita': <strong>{rescanModeLabel(rescanStatus.mode)}</strong>
</span>
)}
</div>
{isRescanRunning && (
<span className="text-xs font-mono text-blue-700">
{rescanStatus.processed.toLocaleString('it-IT')} / {rescanStatus.total.toLocaleString('it-IT')} allegati
</span>
)}
</div>
{(rescanStatus.status === 'running' || rescanStatus.status === 'completed') && rescanStatus.total > 0 && (
<div className="space-y-1">
<ProgressBar
value={rescanStatus.processed}
max={rescanStatus.total}
color={rescanStatus.status === 'completed' ? 'green' : 'blue'}
animated={rescanStatus.status === 'running' && rescanStatus.progress_pct < 5}
/>
<div className="flex justify-between text-xs text-gray-500">
<span>{rescanStatus.progress_pct}% completato</span>
{rescanStatus.elapsed_seconds !== null && (
<span>Durata: {formatElapsed(rescanStatus.elapsed_seconds)}</span>
)}
</div>
</div>
)}
{rescanStatus.status !== 'idle' && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-gray-500">
{rescanStatus.started_at && (
<div>
<span className="text-gray-400">Avviato: </span>
<span>{formatDatetime(rescanStatus.started_at)}</span>
</div>
)}
{rescanStatus.finished_at && (
<div>
<span className="text-gray-400">Terminato: </span>
<span>{formatDatetime(rescanStatus.finished_at)}</span>
</div>
)}
{rescanStatus.started_by && (
<div className="col-span-2">
<span className="text-gray-400">Avviato da: </span>
<span className="font-medium">{rescanStatus.started_by}</span>
</div>
)}
</div>
)}
{rescanStatus.status === 'failed' && rescanStatus.error && (
<div className="p-2 rounded bg-red-50 border border-red-200 text-xs text-red-800 font-mono break-all">
{rescanStatus.error}
</div>
)}
{rescanStatus.status === 'idle' && (
<p className="text-xs text-gray-500">
Nessuna scansione allegati in corso. Usa il pulsante "Riscansiona allegati" per avviarne una.
</p>
)}
</div>
</div>
)}
{/* ── Dialogs conferma ── */}
{showCancelConfirm && (
<div className="rounded-lg bg-red-50 border border-red-200 p-3 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-red-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800">Conferma annullamento reindex</p>
<p className="text-xs text-red-700 mt-0.5">
Il job si fermera' alla fine del batch corrente.
I messaggi gia' indicizzati rimarranno indicizzati.
</p>
<div className="flex gap-3 mt-2">
<button className="text-xs font-medium text-red-800 underline hover:no-underline" onClick={handleCancel}>
Confermo, annulla
</button>
<span className="text-red-400">·</span>
<button className="text-xs font-medium text-gray-600 underline hover:no-underline" onClick={() => setShowCancelConfirm(false)}>
No, lascia proseguire
</button>
</div>
</div>
</div>
)}
{showFullReindexConfirm && (
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-800">Reindex totale</p>
<p className="text-xs text-amber-700 mt-0.5">
Verra' riscritto il vettore di ricerca per tutti i{' '}
<strong>{stats?.total_messages.toLocaleString('it-IT')}</strong> messaggi.
La ricerca rimane disponibile durante il processo.
</p>
<div className="flex gap-3 mt-2">
<button className="text-xs font-medium text-amber-800 underline hover:no-underline" onClick={() => handleStartReindex('full')}>
Confermo, avvia
</button>
<span className="text-amber-500">·</span>
<button className="text-xs font-medium text-gray-600 underline hover:no-underline" onClick={() => setShowFullReindexConfirm(false)}>
Annulla
</button>
</div>
</div>
</div>
)}
{showForceRescanConfirm && (
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-800">Riscansione forzata allegati</p>
<p className="text-xs text-amber-700 mt-0.5">
Il testo verra' ri-estratto da <strong>tutti</strong> gli{' '}
<strong>{stats?.attachments_total.toLocaleString('it-IT')}</strong> allegati del tenant,
sovrascrivendo quelli gia' presenti. Operazione piu' lunga del differenziale.
</p>
<div className="flex gap-3 mt-2">
<button className="text-xs font-medium text-amber-800 underline hover:no-underline" onClick={() => handleStartRescan(true)}>
Confermo, avvia riscansione forzata
</button>
<span className="text-amber-500">·</span>
<button className="text-xs font-medium text-gray-600 underline hover:no-underline" onClick={() => setShowForceRescanConfirm(false)}>
Annulla
</button>
</div>
</div>
</div>
)}
{showRescanCancelConfirm && (
<div className="rounded-lg bg-red-50 border border-red-200 p-3 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-red-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800">Conferma annullamento scansione</p>
<p className="text-xs text-red-700 mt-0.5">
La scansione si fermera' alla fine del batch corrente.
</p>
<div className="flex gap-3 mt-2">
<button className="text-xs font-medium text-red-800 underline hover:no-underline" onClick={handleCancelRescan}>
Confermo, annulla
</button>
<span className="text-red-400">·</span>
<button className="text-xs font-medium text-gray-600 underline hover:no-underline" onClick={() => setShowRescanCancelConfirm(false)}>
No, lascia proseguire
</button>
</div>
</div>
</div>
)}
{/* ── Pulsanti reindex ── */}
<div className="space-y-2">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Reindex messaggi</p>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleStartReindex('differential')}
disabled={actionLoading || anyJobRunning}
className="flex items-center gap-1.5"
>
<RefreshCw className={`h-3.5 w-3.5 ${actionLoading ? 'animate-spin' : ''}`} />
Reindex differenziale
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setShowFullReindexConfirm(true)}
disabled={actionLoading || anyJobRunning || showFullReindexConfirm}
className="flex items-center gap-1.5"
>
<Zap className="h-3.5 w-3.5" />
Reindex totale
</Button>
{isRunning && (
<Button
size="sm"
variant="outline"
onClick={() => setShowCancelConfirm(true)}
disabled={actionLoading || showCancelConfirm}
className="flex items-center gap-1.5 text-red-600 border-red-300 hover:bg-red-50"
>
<XCircle className="h-3.5 w-3.5" />
Termina indicizzazione
</Button>
)}
</div>
</div>
{/* ── Pulsanti scansione allegati ── */}
<div className="space-y-2">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Scansione allegati</p>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleStartRescan(false)}
disabled={rescanActionLoading || anyJobRunning}
className="flex items-center gap-1.5 text-blue-700 border-blue-300 hover:bg-blue-50"
>
<FileText className={`h-3.5 w-3.5 ${rescanActionLoading ? 'animate-pulse' : ''}`} />
Riscansiona allegati
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setShowForceRescanConfirm(true)}
disabled={rescanActionLoading || anyJobRunning || showForceRescanConfirm}
className="flex items-center gap-1.5"
>
<Zap className="h-3.5 w-3.5" />
Riscansione forzata
</Button>
{isRescanRunning && (
<Button
size="sm"
variant="outline"
onClick={() => setShowRescanCancelConfirm(true)}
disabled={rescanActionLoading || showRescanCancelConfirm}
className="flex items-center gap-1.5 text-red-600 border-red-300 hover:bg-red-50"
>
<XCircle className="h-3.5 w-3.5" />
Termina scansione
</Button>
)}
</div>
</div>
{/* Legenda pulsanti */}
<div className="rounded-lg bg-gray-50 border border-gray-100 p-3 space-y-1 text-xs text-gray-500">
<p>
<strong className="text-gray-700">Reindex differenziale</strong> Indicizza solo i
messaggi con <code>search_vector NULL</code>. Rapido, ideale per uso routinario.
</p>
<p>
<strong className="text-gray-700">Reindex totale</strong> Riscrive il vettore di
ricerca per tutti i messaggi, includendo il testo degli allegati gia' estratti.
</p>
<p>
<strong className="text-gray-700">Riscansiona allegati</strong> 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.
</p>
<p>
<strong className="text-gray-700">Riscansione forzata</strong> Come
riscansiona, ma ri-estrae il testo da tutti gli allegati (sovrascrive).
Utile dopo migrazioni o per correggere estrazioni errate.
</p>
</div>
</>
)}
</div>
)}
</Card>
)
}
// ─── 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<TenantSettingsResponse | null>(null)
const [loadingArchival, setLoadingArchival] = useState(false)
const [archivalExpanded, setArchivalExpanded] = useState(false)
// Form archiviazione
const [archivalMode, setArchivalMode] = useState<ArchivalMode>('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<typeof settingsApi.update>[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 (
<div className="p-6 max-w-2xl mx-auto space-y-6">
{/* ── Intestazione ── */}
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-blue-600 flex items-center justify-center">
<Settings className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">Impostazioni</h1>
<p className="text-sm text-gray-500">Gestisci il tuo profilo e le credenziali di accesso</p>
</div>
</div>
{/* ── Card: Informazioni account ── */}
<Card className="p-5 space-y-4">
<div className="flex items-center gap-2 mb-1">
<User className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Informazioni account
</h2>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Email</span>
<p className="mt-0.5 font-medium text-gray-800">{user.email}</p>
</div>
<div>
<span className="text-gray-500">Ruolo</span>
<p className="mt-0.5 font-medium text-gray-800">{roleLabel(user.role)}</p>
</div>
</div>
<hr className="border-gray-100" />
{/* Modifica nome */}
<div className="space-y-2">
<Label htmlFor="full_name">Nome visualizzato</Label>
<div className="flex gap-2">
<Input
id="full_name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Il tuo nome"
className="flex-1"
/>
<Button
onClick={handleSaveName}
disabled={savingName || fullName.trim() === (user.full_name ?? '')}
size="sm"
>
<Save className="h-4 w-4 mr-1.5" />
{savingName ? 'Salvataggio...' : 'Salva'}
</Button>
</div>
</div>
</Card>
{/* ── Card: Cambio password ── */}
<Card className="p-5 space-y-4">
<div className="flex items-center gap-2 mb-1">
<Lock className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Cambio password
</h2>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="new_password">Nuova password</Label>
<Input
id="new_password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Minimo 8 caratteri"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm_password">Conferma password</Label>
<Input
id="confirm_password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Ripeti la nuova password"
/>
</div>
<Button
onClick={handleChangePassword}
disabled={savingPwd || !newPassword || !confirmPassword}
>
<Lock className="h-4 w-4 mr-1.5" />
{savingPwd ? 'Aggiornamento...' : 'Aggiorna password'}
</Button>
</div>
</Card>
{/* ── Card: Archiviazione Sostitutiva (solo admin) ── */}
{isAdmin && (
<Card className="p-5">
{/* Header collassabile */}
<button
className="w-full flex items-center justify-between text-left"
onClick={() => setArchivalExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<Archive className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Archiviazione Sostitutiva
</h2>
{archivalSettings && (
<ArchivalModeBadge mode={archivalSettings.archival_mode} />
)}
</div>
{archivalExpanded
? <ChevronUp className="h-4 w-4 text-gray-400" />
: <ChevronDown className="h-4 w-4 text-gray-400" />
}
</button>
{archivalExpanded && (
<div className="mt-5 space-y-5">
{loadingArchival ? (
<p className="text-sm text-gray-500 py-4 text-center">
Caricamento impostazioni...
</p>
) : (
<>
{/* Toggle modalita' */}
<div className="space-y-2">
<Label>Modalita' conservatore</Label>
<p className="text-xs text-gray-500">
In modalita' <strong>mock</strong> le operazioni di versamento vengono
simulate localmente senza inviare dati a sistemi esterni. Attiva la
modalita' <strong>produzione</strong> solo dopo aver configurato l'endpoint
e le credenziali del conservatore AgID.
</p>
<div className="flex gap-3 mt-2">
<button
type="button"
onClick={() => handleModeToggle('mock')}
className={`flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
archivalMode === 'mock'
? 'border-amber-400 bg-amber-50 text-amber-900'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
}`}
>
<FlaskConical className={`h-6 w-6 ${archivalMode === 'mock' ? 'text-amber-600' : 'text-gray-400'}`} />
<div className="text-center">
<p className="text-sm font-semibold">Mock</p>
<p className="text-xs opacity-75">Simulazione locale</p>
</div>
{archivalMode === 'mock' && <CheckCircle className="h-4 w-4 text-amber-600" />}
</button>
<button
type="button"
onClick={() => handleModeToggle('production')}
className={`flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
archivalMode === 'production'
? 'border-green-500 bg-green-50 text-green-900'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-300'
}`}
>
<Zap className={`h-6 w-6 ${archivalMode === 'production' ? 'text-green-600' : 'text-gray-400'}`} />
<div className="text-center">
<p className="text-sm font-semibold">Produzione</p>
<p className="text-xs opacity-75">Conservatore reale AgID</p>
</div>
{archivalMode === 'production' && <CheckCircle className="h-4 w-4 text-green-600" />}
</button>
</div>
{showProductionConfirm && archivalMode === 'mock' && (
<div className="mt-3 p-3 rounded-lg bg-amber-50 border border-amber-200 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-800">
Stai per attivare la modalita' produzione
</p>
<p className="text-xs text-amber-700 mt-0.5">
I versamenti verranno inviati al conservatore AgID reale.
</p>
<div className="flex gap-2 mt-2">
<button
className="text-xs font-medium text-amber-800 underline"
onClick={() => { setArchivalMode('production'); setShowProductionConfirm(false) }}
>
Confermo, attiva produzione
</button>
<span className="text-amber-600">·</span>
<button
className="text-xs font-medium text-gray-600 underline"
onClick={() => setShowProductionConfirm(false)}
>
Annulla
</button>
</div>
</div>
</div>
)}
</div>
<hr className="border-gray-100" />
<div className="space-y-1.5">
<Label htmlFor="conservatore_id">Identificativo conservatore</Label>
<Input
id="conservatore_id"
value={conservatoreId}
onChange={(e) => setConservatoreId(e.target.value)}
placeholder="es. aruba-cons, docuvision, namirial"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="conservatore_endpoint">
URL endpoint API conservatore
{archivalMode === 'production' && <span className="ml-1 text-red-500">*</span>}
</Label>
<Input
id="conservatore_endpoint"
value={conservatoreEndpoint}
onChange={(e) => setConservatoreEndpoint(e.target.value)}
placeholder="https://conservatore.provider.it/api/v1"
/>
</div>
<div className="space-y-3">
<div>
<Label>Credenziali conservatore</Label>
<p className="text-xs text-gray-500 mt-0.5">
Vengono salvate cifrate (AES-256-GCM).
{archivalSettings?.conservatore_username_configured && (
<span className="ml-1 text-green-600 font-medium">Username configurata</span>
)}
{archivalSettings?.conservatore_password_configured && (
<span className="ml-2 text-green-600 font-medium">Password configurata</span>
)}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="conservatore_username">Username</Label>
<Input
id="conservatore_username"
value={conservatoreUsername}
onChange={(e) => setConservatoreUsername(e.target.value)}
placeholder={archivalSettings?.conservatore_username_configured ? '(credenziale gia\' salvata)' : 'Inserisci username'}
autoComplete="off"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="conservatore_password">Password</Label>
<div className="relative">
<Input
id="conservatore_password"
type={showPassword ? 'text' : 'password'}
value={conservatorePassword}
onChange={(e) => setConservatorePassword(e.target.value)}
placeholder={archivalSettings?.conservatore_password_configured ? '(credenziale gia\' salvata)' : 'Inserisci password'}
autoComplete="new-password"
className="pr-10"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="archival_notes">Note operative (opzionale)</Label>
<textarea
id="archival_notes"
value={archivalNotes}
onChange={(e) => setArchivalNotes(e.target.value)}
placeholder="es. Contratto n. 123/2026 scadenza 31/12/2027"
rows={3}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
{archivalSettings && (
<div className={`rounded-lg p-3 text-xs border ${
archivalSettings.archival_mode === 'production'
? 'bg-green-50 border-green-200 text-green-800'
: 'bg-gray-50 border-gray-200 text-gray-600'
}`}>
<p className="font-medium mb-1">Configurazione attuale (salvata)</p>
<ul className="space-y-0.5">
<li>Modalita': <strong>{archivalSettings.archival_mode === 'production' ? 'Produzione' : 'Mock'}</strong></li>
<li>Conservatore ID: <strong>{archivalSettings.conservatore_id}</strong></li>
{archivalSettings.conservatore_endpoint && (
<li>Endpoint: <strong className="font-mono text-xs break-all">{archivalSettings.conservatore_endpoint}</strong></li>
)}
<li>Credenziali: {
archivalSettings.conservatore_username_configured && archivalSettings.conservatore_password_configured
? <strong className="text-green-700">Configurate</strong>
: <span className="text-gray-500">Non configurate</span>
}</li>
</ul>
</div>
)}
<div className="flex justify-end pt-1">
<Button
onClick={handleSaveArchival}
disabled={savingArchival}
className={archivalMode === 'production' ? 'bg-green-600 hover:bg-green-700' : ''}
>
<Save className="h-4 w-4 mr-1.5" />
{savingArchival ? 'Salvataggio...' : archivalMode === 'production' ? 'Salva e attiva produzione' : 'Salva impostazioni'}
</Button>
</div>
</>
)}
</div>
)}
</Card>
)}
{/* ── Card: Indicizzazione Full-Text (solo admin) ── */}
{isAdmin && (
<IndexingSection isSuperAdmin={isSuperAdmin} />
)}
</div>
)
}