feat(CC-079): add Data Quality page (completeness scores for 6 entity types + action items)

This commit is contained in:
admin-valentin 2026-08-02 12:58:12 +00:00
parent 9857b027c1
commit 36c0dc0ac0

View file

@ -0,0 +1,184 @@
'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 Task { id: string; title: string; status: string; dueDate: string | null; tags: string[]; }
interface Contact { id: string; fullName: string; email: string | null; phone: string | null; consentStatus: string | null; }
interface Document { id: string; title: string; classification: string | null; type: string | null; }
interface Goal { id: string; title: string; status: string; targetDate: string | null; progress: number | null; }
interface Observation { id: string; metric: string; value: string; confidence: number | null; }
interface DataSource { id: string; name: string; status: string; lastSyncAt: string | null; errorCount: number | null; }
function score(good: number, total: number) {
return total === 0 ? 100 : Math.round((good / total) * 100);
}
function cls(pct: number) {
if (pct >= 80) return 'text-signal-ok';
if (pct >= 60) return 'text-warn';
return 'text-signal-danger';
}
export default function DataQualityPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: tasks = [] } = useQuery({ queryKey: ['dq-tasks', tenantId], queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: contacts = [] } = useQuery({ queryKey: ['dq-contacts', tenantId], queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: documents = [] } = useQuery({ queryKey: ['dq-docs', tenantId], queryFn: () => apiFetch<Document[]>('/v1/documents?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: goals = [] } = useQuery({ queryKey: ['dq-goals', tenantId], queryFn: () => apiFetch<Goal[]>('/v1/goals?limit=200', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: observations = [] } = useQuery({ queryKey: ['dq-obs', tenantId], queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: dataSources = [] } = useQuery({ queryKey: ['dq-sources', tenantId], queryFn: () => apiFetch<DataSource[]>('/v1/data-sources', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const metrics = useMemo(() => {
const tasksWithDue = tasks.filter((t) => t.dueDate).length;
const contactsWithEmail = contacts.filter((c) => c.email).length;
const contactsWithConsent = contacts.filter((c) => c.consentStatus === 'granted' || c.consentStatus === 'implicit').length;
const docsClassified = documents.filter((d) => d.classification).length;
const docsTyped = documents.filter((d) => d.type).length;
const goalsWithTarget = goals.filter((g) => g.targetDate).length;
const obsWithConfidence = observations.filter((o) => o.confidence !== null && o.confidence >= 0.5).length;
const sourcesHealthy = dataSources.filter((ds) => ds.status === 'active' && (ds.errorCount ?? 0) === 0).length;
return [
{
category: 'Taskuri', total: tasks.length,
checks: [
{ label: 'Cu termen de scadență', good: tasksWithDue, desc: 'Taskuri care au dueDate setat' },
],
},
{
category: 'Contacte', total: contacts.length,
checks: [
{ label: 'Cu email', good: contactsWithEmail, desc: 'Contacte cu adresă de email' },
{ label: 'Cu consimțământ', good: contactsWithConsent, desc: 'Contacte cu GDPR consent granted/implicit' },
],
},
{
category: 'Documente', total: documents.length,
checks: [
{ label: 'Clasificate (C0-C4)', good: docsClassified, desc: 'Documente cu nivel de clasificare setat' },
{ label: 'Cu tip setat', good: docsTyped, desc: 'Documente cu câmpul type completat' },
],
},
{
category: 'Obiective', total: goals.length,
checks: [
{ label: 'Cu termen țintă', good: goalsWithTarget, desc: 'Obiective cu targetDate' },
],
},
{
category: 'Observații', total: observations.length,
checks: [
{ label: 'Cu confidență ≥ 0.5', good: obsWithConfidence, desc: 'Observații cu scor de încredere adecvat' },
],
},
{
category: 'Surse de date', total: dataSources.length,
checks: [
{ label: 'Surse sănătoase', good: sourcesHealthy, desc: 'Surse active fără erori' },
],
},
];
}, [tasks, contacts, documents, goals, observations, dataSources]);
const overallScore = useMemo(() => {
const scores = metrics.flatMap((m) =>
m.checks.map((c) => score(c.good, m.total))
);
return scores.length === 0 ? 0 : Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);
}, [metrics]);
const issues = useMemo(() => {
const list: { label: string; severity: 'CRITICAL' | 'HIGH' | 'MEDIUM'; action: string; href: string }[] = [];
const contactsNoConsent = contacts.filter((c) => !c.consentStatus || c.consentStatus === 'revoked');
if (contactsNoConsent.length > contacts.length * 0.3)
list.push({ label: `${contactsNoConsent.length} contacte fără consimțământ GDPR`, severity: 'CRITICAL', action: 'Revizuiește consimțăminte', href: '/dashboard/privacy/consents' });
const docsUnclassified = documents.filter((d) => !d.classification).length;
if (docsUnclassified > 5)
list.push({ label: `${docsUnclassified} documente neclasificate`, severity: 'HIGH', action: 'Clasifică documente', href: '/dashboard/documents' });
const sourcesWithErrors = dataSources.filter((ds) => (ds.errorCount ?? 0) > 0).length;
if (sourcesWithErrors > 0)
list.push({ label: `${sourcesWithErrors} surse de date cu erori`, severity: 'HIGH', action: 'Verifică surse', href: '/dashboard/integrations' });
const tasksNoDue = tasks.filter((t) => !t.dueDate && t.status !== 'completed').length;
if (tasksNoDue > 10)
list.push({ label: `${tasksNoDue} taskuri active fără scadență`, severity: 'MEDIUM', action: 'Setează termene', href: '/dashboard/tasks' });
return list;
}, [contacts, documents, dataSources, tasks]);
const SEVERITY_CLS = { CRITICAL: 'text-signal-danger', HIGH: 'text-warn', MEDIUM: 'text-ink-faint' };
return (
<div className="max-w-4xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Calitate Date</h1>
<p className="text-sm text-ink-faint mt-1">Completitudine și consistență a datelor din 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 ${cls(overallScore)}`}>{overallScore}%</p>
<p className="text-xs text-ink-faint mt-1">Scor calitate global</p>
</div>
<div className="flex-1">
<div className="h-3 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full transition-all ${overallScore >= 80 ? 'bg-signal-ok' : overallScore >= 60 ? 'bg-warn' : 'bg-signal-danger'}`}
style={{ width: `${overallScore}%` }} />
</div>
<p className="text-xs text-ink-faint mt-2">
{overallScore >= 80 ? '✅ Date de calitate bună' : overallScore >= 60 ? '⚠ Sunt probleme de adresat' : '❌ Calitate slabă — acțiune necesară'}
</p>
</div>
</div>
{/* Issues */}
{issues.length > 0 && (
<div className="card p-4 space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Probleme detectate</p>
{issues.map((issue, i) => (
<div key={i} className="flex items-center justify-between gap-3 py-2 border-t border-border/50">
<div>
<span className={`text-[9px] font-bold mr-2 ${SEVERITY_CLS[issue.severity]}`}>{issue.severity}</span>
<span className="text-xs text-ink">{issue.label}</span>
</div>
<Link href={issue.href} className="text-[10px] text-primary hover:underline shrink-0">{issue.action} </Link>
</div>
))}
</div>
)}
{/* Detailed metrics */}
<div className="grid gap-4 sm:grid-cols-2">
{metrics.map((m) => (
<div key={m.category} className="card p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-ink">{m.category}</p>
<span className="text-xs text-ink-faint">{m.total} intrări</span>
</div>
{m.checks.map((check) => {
const pct = score(check.good, m.total);
return (
<div key={check.label} className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-ink-faint">{check.label}</span>
<span className={`font-semibold ${cls(pct)}`}>{pct}%</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${pct >= 80 ? 'bg-signal-ok' : pct >= 60 ? 'bg-warn' : 'bg-signal-danger'}`}
style={{ width: `${pct}%` }} />
</div>
<p className="text-[9px] text-ink-faint">{check.good}/{m.total} · {check.desc}</p>
</div>
);
})}
</div>
))}
</div>
</div>
);
}