feat(CC-076): add Data Compliance page (GDPR score + consent + doc classification + source health)
This commit is contained in:
parent
9fe06ea5eb
commit
62ac5f270e
1 changed files with 241 additions and 0 deletions
241
src/app/dashboard/data/compliance/page.tsx
Normal file
241
src/app/dashboard/data/compliance/page.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '../../../../lib/api';
|
||||
import { useSession } from '../../../../components/session-provider';
|
||||
|
||||
interface Contact { id: string; consentStatus: string; tags: string[]; }
|
||||
interface Document { id: string; classification: string | null; ocrStatus: string | null; tags: string[]; }
|
||||
interface DataSource { id: string; name: string; sourceType: string; status: string; lastSyncAt: string | null; }
|
||||
|
||||
const CLS_GDPR: Record<string, { label: string; risk: string; color: string; }> = {
|
||||
C0: { label: 'Public', risk: 'nul', color: 'text-ink-faint' },
|
||||
C1: { label: 'Intern', risk: 'scăzut', color: 'text-signal-ok' },
|
||||
C2: { label: 'Confidențial', risk: 'mediu', color: 'text-warn' },
|
||||
C3: { label: 'Restricționat', risk: 'înalt', color: 'text-orange-500' },
|
||||
C4: { label: 'Secret', risk: 'critic', color: 'text-signal-danger' },
|
||||
};
|
||||
|
||||
export default function DataCompliancePage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
|
||||
const { data: contacts = [], isLoading: cL } = useQuery({
|
||||
queryKey: ['dc-contacts', tenantId],
|
||||
queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=1000', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 120_000,
|
||||
});
|
||||
const { data: docs = [], isLoading: dL } = useQuery({
|
||||
queryKey: ['dc-docs', tenantId],
|
||||
queryFn: () => apiFetch<Document[]>('/v1/documents?limit=500', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 120_000,
|
||||
});
|
||||
const { data: sources = [], isLoading: sL } = useQuery({
|
||||
queryKey: ['dc-sources', tenantId],
|
||||
queryFn: () => apiFetch<DataSource[]>('/v1/data-sources', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 120_000,
|
||||
});
|
||||
|
||||
const isLoading = cL || dL || sL;
|
||||
|
||||
const stats = useMemo(() => {
|
||||
// Consent breakdown
|
||||
const consent: Record<string, number> = {};
|
||||
for (const c of contacts) consent[c.consentStatus] = (consent[c.consentStatus] ?? 0) + 1;
|
||||
|
||||
// Classification breakdown
|
||||
const cls: Record<string, number> = {};
|
||||
for (const d of docs) cls[d.classification ?? 'unclassified'] = (cls[d.classification ?? 'unclassified'] ?? 0) + 1;
|
||||
|
||||
// Data sources by type
|
||||
const srcTypes: Record<string, number> = {};
|
||||
for (const s of sources) srcTypes[s.sourceType] = (srcTypes[s.sourceType] ?? 0) + 1;
|
||||
const errorSources = sources.filter((s) => s.status === 'error');
|
||||
const staleSources = sources.filter((s) => s.lastSyncAt && (Date.now() - new Date(s.lastSyncAt).getTime()) > 7 * 86400_000);
|
||||
|
||||
// GDPR score
|
||||
const granted = consent['granted'] ?? 0;
|
||||
const revoked = consent['revoked'] ?? 0;
|
||||
const total = contacts.length;
|
||||
const consentScore = total > 0 ? Math.round((granted / total) * 100) : 100;
|
||||
|
||||
// Doc classification score (% classified C0-C4)
|
||||
const classified = Object.entries(cls).filter(([k]) => k !== 'unclassified').reduce((s, [,v]) => s + v, 0);
|
||||
const classScore = docs.length > 0 ? Math.round((classified / docs.length) * 100) : 100;
|
||||
|
||||
// Source health score
|
||||
const sourceScore = sources.length > 0
|
||||
? Math.round(((sources.length - errorSources.length) / sources.length) * 100)
|
||||
: 100;
|
||||
|
||||
const overallScore = Math.round((consentScore + classScore + sourceScore) / 3);
|
||||
|
||||
return { consent, cls, srcTypes, errorSources, staleSources, consentScore, classScore, sourceScore, overallScore, granted, revoked, total, classified };
|
||||
}, [contacts, docs, sources]);
|
||||
|
||||
const scoreCls = (s: number) => s >= 80 ? 'text-signal-ok' : s >= 60 ? 'text-warn' : 'text-signal-danger';
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Conformitate Date</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
GDPR + clasificare date + sănătate surse — platformă de date CEO OS.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Overall score */}
|
||||
<div className="card p-6 flex items-center gap-6">
|
||||
<div className="text-center">
|
||||
<p className={`font-display text-5xl font-bold ${scoreCls(stats.overallScore)}`}>{stats.overallScore}</p>
|
||||
<p className="text-xs text-ink-faint mt-1">scor conformitate</p>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{[
|
||||
{ label: 'Consimțământ GDPR', score: stats.consentScore, href: '/dashboard/privacy/consents' },
|
||||
{ label: 'Clasificare documente', score: stats.classScore, href: '/dashboard/privacy/classification' },
|
||||
{ label: 'Sănătate surse de date', score: stats.sourceScore, href: '/dashboard/data/sources' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="space-y-0.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
<Link href={s.href} className="text-ink hover:underline">{s.label}</Link>
|
||||
<span className={`font-semibold ${scoreCls(s.score)}`}>{s.score}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div className={`h-full rounded-full ${s.score >= 80 ? 'bg-signal-ok' : s.score >= 60 ? 'bg-warn' : 'bg-signal-danger'}`}
|
||||
style={{ width: `${s.score}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-4">Se încarcă…</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{/* Consent */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Consimțământ GDPR</p>
|
||||
<Link href="/dashboard/privacy/consents" className="text-[10px] text-primary hover:underline">Detalii →</Link>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(stats.consent).map(([status, count]) => {
|
||||
const pct = stats.total > 0 ? Math.round((count / stats.total) * 100) : 0;
|
||||
return (
|
||||
<div key={status}>
|
||||
<div className="flex justify-between text-xs mb-0.5">
|
||||
<span className="text-ink">{status}</span>
|
||||
<span className="text-ink-faint">{count} ({pct}%)</span>
|
||||
</div>
|
||||
<div className="h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div className={`h-full rounded-full ${status === 'granted' ? 'bg-signal-ok' : status === 'revoked' ? 'bg-signal-danger' : 'bg-muted-foreground'}`}
|
||||
style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{stats.revoked > 0 && (
|
||||
<p className="text-[10px] text-signal-danger font-medium">
|
||||
⚠ {stats.revoked} persoane cu GDPR revocat necesită acțiune.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document classification */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Clasificare documente</p>
|
||||
<Link href="/dashboard/privacy/classification" className="text-[10px] text-primary hover:underline">Detalii →</Link>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(stats.cls).map(([cls, count]) => {
|
||||
const pct = docs.length > 0 ? Math.round((count / docs.length) * 100) : 0;
|
||||
const cfg = CLS_GDPR[cls];
|
||||
return (
|
||||
<div key={cls}>
|
||||
<div className="flex justify-between text-xs mb-0.5">
|
||||
<span className={cfg?.color ?? 'text-ink-faint'}>{cls} {cfg ? `(${cfg.label})` : ''}</span>
|
||||
<span className="text-ink-faint">{count}</span>
|
||||
</div>
|
||||
<div className="h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full rounded-full bg-primary/40" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{stats.classified < docs.length && (
|
||||
<p className="text-[10px] text-warn font-medium">
|
||||
⚠ {docs.length - stats.classified} documente neclasificate.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Data sources health */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Surse de date</p>
|
||||
<Link href="/dashboard/data/sources" className="text-[10px] text-primary hover:underline">Detalii →</Link>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink">Total surse</span>
|
||||
<span className="font-mono text-ink">{sources.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-signal-ok">Active</span>
|
||||
<span className="font-mono text-signal-ok">{sources.filter(s => s.status === 'active').length}</span>
|
||||
</div>
|
||||
{stats.errorSources.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-signal-danger">Erori</span>
|
||||
<span className="font-mono text-signal-danger">{stats.errorSources.length}</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.staleSources.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-warn">Stale (>7z)</span>
|
||||
<span className="font-mono text-warn">{stats.staleSources.length}</span>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(stats.srcTypes).map(([type, count]) => (
|
||||
<div key={type} className="flex justify-between text-ink-faint">
|
||||
<span>{type}</span><span className="font-mono">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action items */}
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Acțiuni recomandate</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
stats.revoked > 0 && { href: '/dashboard/privacy/consents', severity: 'CRITICAL', text: `${stats.revoked} contacte cu GDPR revocat — șterge sau anonimizează datele.` },
|
||||
stats.errorSources.length > 0 && { href: '/dashboard/data/sources', severity: 'HIGH', text: `${stats.errorSources.length} surse de date în eroare — verifică conexiunile.` },
|
||||
stats.staleSources.length > 0 && { href: '/dashboard/data/sources', severity: 'MEDIUM', text: `${stats.staleSources.length} surse fără sincronizare >7 zile.` },
|
||||
(stats.classified < docs.length) && { href: '/dashboard/privacy/classification', severity: 'MEDIUM', text: `${docs.length - stats.classified} documente neclasificate GDPR.` },
|
||||
].filter(Boolean).map((action: any) => (
|
||||
<Link key={action.href + action.text} href={action.href}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors">
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[9px] font-bold ${action.severity === 'CRITICAL' ? 'bg-signal-danger/10 text-signal-danger' : action.severity === 'HIGH' ? 'bg-warn/10 text-warn' : 'bg-muted text-ink-faint'}`}>
|
||||
{action.severity}
|
||||
</span>
|
||||
<p className="text-xs text-ink">{action.text}</p>
|
||||
</Link>
|
||||
))}
|
||||
{stats.revoked === 0 && stats.errorSources.length === 0 && stats.classified >= docs.length && (
|
||||
<p className="text-xs text-signal-ok text-center py-2">✅ Nicio acțiune urgentă necesară.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue