diff --git a/src/app/dashboard/data/quality/page.tsx b/src/app/dashboard/data/quality/page.tsx new file mode 100644 index 0000000..fa2c594 --- /dev/null +++ b/src/app/dashboard/data/quality/page.tsx @@ -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('/v1/tasks?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 }); + const { data: contacts = [] } = useQuery({ queryKey: ['dq-contacts', tenantId], queryFn: () => apiFetch('/v1/contacts?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 }); + const { data: documents = [] } = useQuery({ queryKey: ['dq-docs', tenantId], queryFn: () => apiFetch('/v1/documents?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 }); + const { data: goals = [] } = useQuery({ queryKey: ['dq-goals', tenantId], queryFn: () => apiFetch('/v1/goals?limit=200', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 }); + const { data: observations = [] } = useQuery({ queryKey: ['dq-obs', tenantId], queryFn: () => apiFetch('/v1/observations', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 }); + const { data: dataSources = [] } = useQuery({ queryKey: ['dq-sources', tenantId], queryFn: () => apiFetch('/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 ( +
+
+

Calitate Date

+

Completitudine și consistență a datelor din CEO OS.

+
+ + {/* Overall score */} +
+
+

{overallScore}%

+

Scor calitate global

+
+
+
+
= 80 ? 'bg-signal-ok' : overallScore >= 60 ? 'bg-warn' : 'bg-signal-danger'}`} + style={{ width: `${overallScore}%` }} /> +
+

+ {overallScore >= 80 ? '✅ Date de calitate bună' : overallScore >= 60 ? '⚠ Sunt probleme de adresat' : '❌ Calitate slabă — acțiune necesară'} +

+
+
+ + {/* Issues */} + {issues.length > 0 && ( +
+

Probleme detectate

+ {issues.map((issue, i) => ( +
+
+ {issue.severity} + {issue.label} +
+ {issue.action} → +
+ ))} +
+ )} + + {/* Detailed metrics */} +
+ {metrics.map((m) => ( +
+
+

{m.category}

+ {m.total} intrări +
+ {m.checks.map((check) => { + const pct = score(check.good, m.total); + return ( +
+
+ {check.label} + {pct}% +
+
+
= 80 ? 'bg-signal-ok' : pct >= 60 ? 'bg-warn' : 'bg-signal-danger'}`} + style={{ width: `${pct}%` }} /> +
+

{check.good}/{m.total} · {check.desc}

+
+ ); + })} +
+ ))} +
+
+ ); +}