feat(CC-066): add Data Classification page (C0-C4 breakdown, GDPR notes)

This commit is contained in:
admin-valentin 2026-08-02 12:09:10 +00:00
parent 9d35476169
commit 0f18b14333

View file

@ -0,0 +1,136 @@
'use client';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
interface Document {
id: string; filename: string; classification: string;
ocrStatus: string | null; expiresAt: string | null; createdAt: string;
}
const CLASS_META: Record<string, {
label: string; desc: string; cls: string; gdpr: string;
}> = {
C0: { label: 'C0 — Public', desc: 'Informații publice, fără restricții', cls: 'text-sky-500 bg-sky-500/10 border-sky-500/20', gdpr: 'Fără restricții GDPR' },
C1: { label: 'C1 — Intern', desc: 'Uz intern, distribuire controlată', cls: 'text-signal-ok bg-signal-ok/10 border-signal-ok/20', gdpr: 'Art. 5 — Integritate' },
C2: { label: 'C2 — Confidențial', desc: 'Date comerciale sensibile, parteneri', cls: 'text-signal-warn bg-signal-warn/10 border-signal-warn/20', gdpr: 'Art. 5 — Confidențialitate' },
C3: { label: 'C3 — Restricționat',desc: 'Date personale, financiare, juridice', cls: 'text-orange-500 bg-orange-500/10 border-orange-500/20', gdpr: 'Art. 9 — Date speciale' },
C4: { label: 'C4 — Secret', desc: 'NDA, secrete comerciale, date biometrice sau medicale', cls: 'text-signal-danger bg-signal-danger/10 border-signal-danger/20', gdpr: 'Art. 9 + DPA obligatoriu' },
};
export default function ClassificationPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: docs = [], isLoading } = useQuery({
queryKey: ['docs-class', tenantId],
queryFn: () => apiFetch<Document[]>('/v1/documents?limit=500', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 120_000,
});
const total = docs.length;
const byClass: Record<string, Document[]> = {};
for (const d of docs) {
const k = d.classification ?? 'C1';
byClass[k] = [...(byClass[k] ?? []), d];
}
const highRisk = (byClass['C3'] ?? []).length + (byClass['C4'] ?? []).length;
const ocrPending = docs.filter((d) => d.ocrStatus === 'pending').length;
const withExpiry = docs.filter((d) => d.expiresAt).length;
return (
<div className="max-w-4xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Clasificare Date</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${total} documente · ${highRisk} cu risc ridicat (C3/C4)`}
</p>
</div>
{/* Quick stats */}
<div className="grid grid-cols-3 gap-3">
<div className="card p-4 text-center">
<p className="font-display text-3xl font-bold text-ink">{total}</p>
<p className="text-xs text-ink-faint mt-1">Total documente</p>
</div>
<div className={`card p-4 text-center ${highRisk > 0 ? 'bg-signal-danger/5' : ''}`}>
<p className={`font-display text-3xl font-bold ${highRisk > 0 ? 'text-signal-danger' : 'text-ink'}`}>{highRisk}</p>
<p className="text-xs text-ink-faint mt-1">C3/C4 (risc ridicat)</p>
</div>
<div className="card p-4 text-center">
<p className="font-display text-3xl font-bold text-ink">{withExpiry}</p>
<p className="text-xs text-ink-faint mt-1">Cu dată expirare</p>
</div>
</div>
{isLoading ? (
<div className="card p-8 text-center text-sm text-ink-faint">Se încarcă</div>
) : total === 0 ? (
<div className="card p-12 text-center space-y-2">
<p className="text-3xl">📁</p>
<p className="text-sm text-ink-faint">Niciun document în bibliotecă.</p>
<Link href="/dashboard/documents" className="text-xs text-bronze-deep hover:underline">Accesează biblioteca </Link>
</div>
) : (
<div className="space-y-4">
{(['C4','C3','C2','C1','C0'] as const).map((cls) => {
const items = byClass[cls] ?? [];
const meta = CLASS_META[cls];
const pct = total > 0 ? Math.round(items.length / total * 100) : 0;
if (items.length === 0) return null;
return (
<div key={cls} className={`card p-5 border ${meta.cls.split(' ').find(c => c.startsWith('border-')) ?? 'border-border'}`}>
<div className="flex items-start justify-between gap-4 mb-3">
<div>
<h2 className={`text-sm font-semibold ${meta.cls.split(' ')[0]}`}>{meta.label}</h2>
<p className="text-xs text-ink-faint mt-0.5">{meta.desc}</p>
</div>
<div className="text-right shrink-0">
<p className={`font-display text-2xl font-bold ${meta.cls.split(' ')[0]}`}>{items.length}</p>
<p className="text-[10px] text-ink-faint">{pct}% din total</p>
</div>
</div>
{/* Progress bar */}
<div className="h-1.5 rounded-full bg-muted/50 mb-3">
<div className={`h-1.5 rounded-full ${meta.cls.split(' ').find(c => c.startsWith('bg-')) ?? 'bg-primary'}`}
style={{ width: `${pct}%` }} />
</div>
{/* GDPR note */}
<p className="text-[10px] text-ink-faint mb-3"> {meta.gdpr}</p>
{/* Recent docs sample */}
<div className="divide-y divide-border/30">
{items.slice(0, 5).map((d) => (
<div key={d.id} className="flex items-center gap-2 py-1.5">
<span className="text-sm">📄</span>
<span className="text-xs text-ink truncate flex-1">{d.filename}</span>
{d.expiresAt && <span className="text-[10px] text-signal-warn shrink-0">exp: {d.expiresAt.slice(0, 10)}</span>}
</div>
))}
{items.length > 5 && (
<p className="text-[10px] text-ink-faint pt-1.5">și alte {items.length - 5} documente</p>
)}
</div>
</div>
);
})}
</div>
)}
{ocrPending > 0 && (
<div className="rounded-lg border border-signal-warn/20 bg-signal-warn/5 p-4">
<p className="text-xs text-signal-warn">
<strong>{ocrPending} documente</strong> au OCR pendente clasificarea poate fi incompletă.{' '}
<Link href="/dashboard/documents/ocr" className="underline">Revizuiește OCR </Link>
</p>
</div>
)}
</div>
);
}