mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
1447 lines
61 KiB
TypeScript
1447 lines
61 KiB
TypeScript
/**
|
||
* 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>
|
||
)
|
||
}
|