feat(CC-079): add Data Quality page (completeness scores for 6 entity types + action items)
This commit is contained in:
parent
9857b027c1
commit
36c0dc0ac0
1 changed files with 184 additions and 0 deletions
184
src/app/dashboard/data/quality/page.tsx
Normal file
184
src/app/dashboard/data/quality/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue