feat(CC-066): add Data Classification page (C0-C4 breakdown, GDPR notes)
This commit is contained in:
parent
9d35476169
commit
0f18b14333
1 changed files with 136 additions and 0 deletions
136
src/app/dashboard/privacy/classification/page.tsx
Normal file
136
src/app/dashboard/privacy/classification/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue