feat(CC-076): add Data Compliance page (GDPR score + consent + doc classification + source health)

This commit is contained in:
admin-valentin 2026-08-02 12:44:18 +00:00
parent 9fe06ea5eb
commit 62ac5f270e

View 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 (&gt;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>
);
}