Conservazione in Sidebar

This commit is contained in:
2026-06-18 11:53:18 +02:00
parent c68daf4313
commit 042a522854
6 changed files with 753 additions and 6 deletions
+4
View File
@@ -23,6 +23,7 @@ import { FascicoliPage } from '@/pages/Fascicoli/FascicoliPage'
import { FascicoloDetailPage } from '@/pages/Fascicoli/FascicoloDetailPage'
import { TaxonomyPage } from '@/pages/Taxonomy/TaxonomyPage'
import { PermissionPresetsPage } from '@/pages/PermissionPresets/PermissionPresetsPage'
import { ConservationPage } from '@/pages/Conservation/ConservationPage'
/**
* Routing principale dell'applicazione PEChub.
@@ -113,6 +114,9 @@ export default function App() {
{/* Tassonomia di classificazione multi-livello (N2) */}
<Route path="/taxonomy" element={<TaxonomyPage />} />
{/* Conservazione sostitutiva (admin) */}
<Route path="/conservation" element={<ConservationPage />} />
{/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} />
+65 -6
View File
@@ -2,12 +2,14 @@
* API client per le impostazioni del tenant.
*
* Endpoint:
* GET /api/v1/settings -> legge configurazione (admin)
* PUT /api/v1/settings -> aggiorna configurazione (admin)
* GET /api/v1/settings/indexing/stats -> statistiche indicizzazione
* GET /api/v1/settings/indexing/status -> stato job reindex
* POST /api/v1/settings/indexing/reindex -> avvia reindex
* DELETE /api/v1/settings/indexing/reindex -> cancella reindex
* GET /api/v1/settings -> legge configurazione (admin)
* PUT /api/v1/settings -> aggiorna configurazione (admin)
* GET /api/v1/settings/indexing/stats -> statistiche indicizzazione
* GET /api/v1/settings/indexing/status -> stato job reindex
* POST /api/v1/settings/indexing/reindex -> avvia reindex
* DELETE /api/v1/settings/indexing/reindex -> cancella reindex
* GET /api/v1/settings/conservation/stats -> statistiche conservazione
* POST /api/v1/settings/conservation/trigger -> avvia job conservazione manuale
*/
import { apiClient } from './client'
@@ -83,6 +85,35 @@ export interface IndexingJobStatus {
error: string | null
}
// ─── Tipi conservazione sostitutiva ───────────────────────────────────────
export interface ConservationStats {
total_messages: number
conserved: number
pending: number
not_queued: number
coverage_pct: number // 0-100
last_conserved_at: string | null // ISO datetime
}
export interface ConservationTenantBreakdown {
tenant_id: string
tenant_name: string
tenant_slug: string
stats: ConservationStats
}
export interface ConservationStatsResponse {
stats: ConservationStats
per_tenant: ConservationTenantBreakdown[] | null
}
export interface ConservationTriggerResult {
queued: boolean
message: string
job_id: string | null
}
// ─── Client impostazioni generali ──────────────────────────────────────────
export const settingsApi = {
@@ -211,4 +242,32 @@ export const settingsApi = {
)
return data
},
// ── Conservazione sostitutiva ─────────────────────────────────────────────
/**
* Restituisce le statistiche di conservazione sostitutiva.
* admin: stats del proprio tenant.
* super_admin senza tenantId: stats aggregate su tutti i tenant + per_tenant.
* super_admin con tenantId: stats del tenant specificato.
*/
getConservationStats: async (tenantId?: string): Promise<ConservationStatsResponse> => {
const params = tenantId ? { tenant_id: tenantId } : undefined
const { data } = await apiClient.get<ConservationStatsResponse>(
'/settings/conservation/stats',
{ params }
)
return data
},
/**
* Avvia manualmente il job di conservazione sostitutiva.
* Il job verra' eseguito dal worker entro pochi secondi.
*/
triggerConservation: async (): Promise<ConservationTriggerResult> => {
const { data } = await apiClient.post<ConservationTriggerResult>(
'/settings/conservation/trigger'
)
return data
},
}
@@ -587,6 +587,7 @@ export function Sidebar() {
{ to: '/signatures', label: 'Firme automatiche', icon: PenLine },
{ to: '/notifications', label: 'Notifiche', icon: Bell },
{ to: '/audit-log', label: 'Audit Log', icon: ClipboardList },
{ to: '/conservation', label: 'Conservazione', icon: ShieldCheck },
] as const).map((item) => (
<NavLink
key={item.to}
@@ -0,0 +1,465 @@
/**
* ConservationPage statistiche e gestione manuale della conservazione sostitutiva.
*
* Visibile solo ad admin e super_admin.
*
* Funzionalita':
* - Statistiche messaggi: totali, conservati, in coda, non accodati
* - Barra di copertura (% conservati su totali)
* - Data ultimo messaggio conservato
* - Super_admin: dropdown per selezionare un tenant specifico o "tutti i tenant"
* con tabella breakdown per-tenant
* - Pulsante "Avvia conservazione manuale" con dialogo di conferma
*/
import { useEffect, useState } from 'react'
import {
ShieldCheck,
RefreshCw,
Play,
AlertTriangle,
CheckCircle,
Clock,
Loader2,
BarChart3,
User,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
import { tenantsApi } from '@/api/tenants.api'
import {
settingsApi,
type ConservationStatsResponse,
type ConservationStats,
} from '@/api/settings.api'
import type { TenantResponse } from '@/types/api.types'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
import toast from 'react-hot-toast'
// ─── Utility ─────────────────────────────────────────────────────────────────
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',
})
}
// ─── Barra di avanzamento ─────────────────────────────────────────────────────
function ProgressBar({
value,
max = 100,
color = 'teal',
}: {
value: number
max?: number
color?: 'teal' | 'green' | 'amber' | 'red' | 'gray'
}) {
const pct = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0
const colorClass = {
teal: 'bg-teal-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.5 overflow-hidden">
<div
className={`h-2.5 rounded-full transition-all duration-500 ${colorClass}`}
style={{ width: `${pct}%` }}
/>
</div>
)
}
// ─── Card stat singola ────────────────────────────────────────────────────────
function StatCard({
label,
value,
colorClass = 'text-gray-800',
bgClass = 'bg-gray-50',
borderClass = 'border-gray-200',
}: {
label: string
value: number
colorClass?: string
bgClass?: string
borderClass?: string
}) {
return (
<div className={`rounded-lg border p-3 text-center ${bgClass} ${borderClass}`}>
<p className={`text-2xl font-bold ${colorClass}`}>
{value.toLocaleString('it-IT')}
</p>
<p className="text-xs text-gray-500 mt-0.5">{label}</p>
</div>
)
}
// ─── Blocco statistiche (riusato per singolo tenant e aggregato) ──────────────
function StatsBlock({ stats }: { stats: ConservationStats }) {
const coverageColor = stats.coverage_pct >= 90 ? 'teal' : stats.coverage_pct >= 60 ? 'amber' : 'red'
const coverageTextColor =
stats.coverage_pct >= 90 ? 'text-teal-700' :
stats.coverage_pct >= 60 ? 'text-amber-700' : 'text-red-700'
return (
<div className="space-y-4">
{/* Griglia statistiche */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Totali" value={stats.total_messages} />
<StatCard
label="Conservati"
value={stats.conserved}
colorClass="text-teal-700"
bgClass="bg-teal-50"
borderClass="border-teal-200"
/>
<StatCard
label="In coda"
value={stats.pending}
colorClass={stats.pending > 0 ? 'text-amber-700' : 'text-gray-500'}
bgClass={stats.pending > 0 ? 'bg-amber-50' : 'bg-gray-50'}
borderClass={stats.pending > 0 ? 'border-amber-200' : 'border-gray-200'}
/>
<StatCard
label="Non accodati"
value={stats.not_queued}
colorClass={stats.not_queued > 0 ? 'text-gray-600' : 'text-gray-400'}
/>
</div>
{/* Barra copertura */}
<div className="space-y-1.5">
<div className="flex justify-between items-center text-xs">
<span className="text-gray-600">Copertura conservazione</span>
<span className={`font-semibold ${coverageTextColor}`}>
{stats.coverage_pct}%
</span>
</div>
<ProgressBar
value={stats.conserved}
max={stats.total_messages}
color={coverageColor}
/>
{stats.pending > 0 && (
<p className="text-xs text-amber-600">
{stats.pending.toLocaleString('it-IT')} messaggi in coda verranno elaborati al prossimo run.
</p>
)}
</div>
{/* Ultima conservazione */}
<div className="flex items-center gap-2 text-xs text-gray-500">
<Clock className="h-3.5 w-3.5 flex-shrink-0" />
<span>
Ultima conservazione: <strong className="text-gray-700">{formatDatetime(stats.last_conserved_at)}</strong>
</span>
</div>
</div>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
export function ConservationPage() {
const { user } = useAuth()
const isSuperAdmin = user?.role === 'super_admin'
const [data, setData] = useState<ConservationStatsResponse | null>(null)
const [loading, setLoading] = useState(false)
const [triggering, setTriggering] = useState(false)
const [showTriggerConfirm, setShowTriggerConfirm] = useState(false)
const [perTenantExpanded, setPerTenantExpanded] = useState(false)
// Selezione tenant per super_admin
const [tenantList, setTenantList] = useState<TenantResponse[]>([])
// selectedTenantId vuoto = "tutti i tenant" (solo super_admin)
const [selectedTenantId, setSelectedTenantId] = useState<string>('')
// Carica lista tenant per super_admin
useEffect(() => {
if (isSuperAdmin) {
tenantsApi.list().then(setTenantList).catch(() => {})
}
}, [isSuperAdmin])
const loadStats = async () => {
setLoading(true)
try {
const tenantId = isSuperAdmin && selectedTenantId ? selectedTenantId : undefined
const result = await settingsApi.getConservationStats(tenantId)
setData(result)
} catch {
toast.error('Errore durante il caricamento delle statistiche di conservazione')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadStats()
}, [selectedTenantId])
const handleTrigger = async () => {
setTriggering(true)
setShowTriggerConfirm(false)
try {
const result = await settingsApi.triggerConservation()
if (result.queued) {
toast.success(result.message)
// Ricarica le statistiche dopo qualche secondo
setTimeout(() => loadStats(), 3000)
} else {
toast.error('Il job non e\' stato accodato')
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })
?.response?.data?.detail
toast.error(msg ?? 'Errore durante l\'avvio della conservazione')
} finally {
setTriggering(false)
}
}
if (!user) return null
// Titolo sezione basato sulla selezione
const sectionTitle = isSuperAdmin && !selectedTenantId
? 'Tutti i tenant (aggregato)'
: isSuperAdmin && selectedTenantId
? (tenantList.find((t) => t.id === selectedTenantId)?.name ?? 'Tenant selezionato')
: 'Il tuo tenant'
return (
<div className="p-6 max-w-3xl mx-auto space-y-6">
{/* ── Intestazione ── */}
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-teal-600 flex items-center justify-center">
<ShieldCheck className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">Conservazione Sostitutiva</h1>
<p className="text-sm text-gray-500">
Statistiche e gestione del ciclo di conservazione documentale AgID
</p>
</div>
</div>
{/* ── Selector tenant (solo super_admin) ── */}
{isSuperAdmin && tenantList.length > 0 && (
<Card className="p-4">
<div className="flex items-center gap-2 mb-3">
<User className="h-3.5 w-3.5 text-purple-600" />
<span className="text-xs font-semibold text-purple-800 uppercase tracking-wide">
Selezione Tenant (Super Admin)
</span>
</div>
<select
className="w-full rounded-md border border-purple-300 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-purple-400"
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
>
<option value="">Tutti i tenant (aggregato globale)</option>
{tenantList.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.slug}){!t.is_active ? ' — SOSPESO' : ''}
</option>
))}
</select>
</Card>
)}
{/* ── Card statistiche ── */}
<Card className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Statistiche {sectionTitle}
</h2>
</div>
<button
className="text-xs text-teal-600 hover:text-teal-800 flex items-center gap-1 disabled:opacity-50"
onClick={loadStats}
disabled={loading}
>
<RefreshCw className={`h-3 w-3 ${loading ? 'animate-spin' : ''}`} />
Aggiorna
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-10 gap-2 text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Caricamento statistiche...</span>
</div>
) : data ? (
<StatsBlock stats={data.stats} />
) : (
<p className="text-sm text-gray-400 text-center py-6">Nessun dato disponibile</p>
)}
</Card>
{/* ── Tabella per-tenant (solo super_admin senza selezione specifica) ── */}
{isSuperAdmin && !selectedTenantId && data?.per_tenant && data.per_tenant.length > 0 && (
<Card className="p-5">
<button
className="w-full flex items-center justify-between text-left mb-1"
onClick={() => setPerTenantExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-gray-400" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Dettaglio per tenant ({data.per_tenant.length})
</h2>
</div>
{perTenantExpanded
? <ChevronUp className="h-4 w-4 text-gray-400" />
: <ChevronDown className="h-4 w-4 text-gray-400" />
}
</button>
{perTenantExpanded && (
<div className="mt-4 overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 pr-3 font-semibold text-gray-500 uppercase tracking-wide">Tenant</th>
<th className="text-right py-2 px-2 font-semibold text-gray-500 uppercase tracking-wide">Totali</th>
<th className="text-right py-2 px-2 font-semibold text-teal-600 uppercase tracking-wide">Conservati</th>
<th className="text-right py-2 px-2 font-semibold text-amber-600 uppercase tracking-wide">In coda</th>
<th className="text-right py-2 px-2 font-semibold text-gray-500 uppercase tracking-wide">Non accodati</th>
<th className="text-right py-2 pl-2 font-semibold text-gray-500 uppercase tracking-wide">Copertura</th>
</tr>
</thead>
<tbody>
{data.per_tenant.map((t) => (
<tr key={t.tenant_id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-2 pr-3">
<p className="font-medium text-gray-800">{t.tenant_name}</p>
<p className="text-gray-400">{t.tenant_slug}</p>
</td>
<td className="text-right py-2 px-2 text-gray-700">
{t.stats.total_messages.toLocaleString('it-IT')}
</td>
<td className="text-right py-2 px-2 font-medium text-teal-700">
{t.stats.conserved.toLocaleString('it-IT')}
</td>
<td className={`text-right py-2 px-2 font-medium ${t.stats.pending > 0 ? 'text-amber-600' : 'text-gray-400'}`}>
{t.stats.pending.toLocaleString('it-IT')}
</td>
<td className="text-right py-2 px-2 text-gray-500">
{t.stats.not_queued.toLocaleString('it-IT')}
</td>
<td className="text-right py-2 pl-2">
<span className={`font-semibold ${
t.stats.coverage_pct >= 90 ? 'text-teal-700' :
t.stats.coverage_pct >= 60 ? 'text-amber-600' : 'text-red-600'
}`}>
{t.stats.coverage_pct}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
{/* ── Card azione manuale ── */}
<Card className="p-5 space-y-4">
<div className="flex items-center gap-2">
<Play className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Azione Manuale
</h2>
</div>
<div className="rounded-lg bg-gray-50 border border-gray-200 p-3 text-xs text-gray-600 space-y-1">
<p>
Il ciclo di conservazione viene eseguito automaticamente ogni giorno alle <strong>18:00</strong> (ora italiana).
Usa questo pulsante per avviarlo immediatamente senza attendere il cron giornaliero.
</p>
<p>
Il job elabora tutti i tenant con messaggi pendenti (<code>is_pending_conservation=TRUE</code>)
e invia i pacchetti SIP al conservatore configurato. I messaggi gia' conservati
(<code>is_conserved=TRUE</code>) vengono saltati.
</p>
</div>
{/* Dialogo conferma */}
{showTriggerConfirm && (
<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">Conferma avvio conservazione</p>
<p className="text-xs text-amber-700 mt-0.5">
Il job verra' accodato immediatamente e il worker lo eseguira' entro pochi secondi.
Vengono processati tutti i tenant con messaggi pendenti.
</p>
<div className="flex gap-3 mt-2">
<button
className="text-xs font-medium text-amber-800 underline hover:no-underline"
onClick={handleTrigger}
disabled={triggering}
>
Confermo, avvia ora
</button>
<span className="text-amber-500">·</span>
<button
className="text-xs font-medium text-gray-600 underline hover:no-underline"
onClick={() => setShowTriggerConfirm(false)}
>
Annulla
</button>
</div>
</div>
</div>
)}
<Button
onClick={() => setShowTriggerConfirm(true)}
disabled={triggering || showTriggerConfirm}
className="flex items-center gap-2 bg-teal-600 hover:bg-teal-700 text-white"
>
{triggering
? <Loader2 className="h-4 w-4 animate-spin" />
: <Play className="h-4 w-4" />
}
{triggering ? 'Avvio in corso...' : 'Avvia conservazione manuale'}
</Button>
</Card>
{/* ── Info ciclo cron ── */}
<div className="rounded-lg bg-teal-50 border border-teal-100 p-4 text-xs text-teal-800 space-y-1">
<p className="font-medium">Come funziona il ciclo di conservazione</p>
<p>
Il worker arq esegue automaticamente <code>run_conservation</code> ogni giorno alle 18:00 (ora italiana).
Per ogni messaggio marcato <em>da conservare</em>, viene costruito un pacchetto SIP BagIt completo
(EML principale + allegati + ricevute PEC) e inviato al conservatore configurato (es. Aeterna).
Dopo il successo, il messaggio viene marcato <code>is_conserved=TRUE</code>.
In caso di errore rimane in coda e verra' ritentato al prossimo run giornaliero.
</p>
<p className="flex items-center gap-1 mt-1">
<CheckCircle className="h-3.5 w-3.5 text-teal-600 flex-shrink-0" />
La conservazione e' idempotente: i messaggi gia' conservati non vengono rielaborati.
</p>
</div>
</div>
)
}