feat(CC-080): add Enrichment page (completeness scores + missing field badges for contacts/orgs)

This commit is contained in:
admin-valentin 2026-08-02 13:02:11 +00:00
parent e75528a681
commit e773d973ea

View file

@ -0,0 +1,166 @@
'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; fullName: string; email: string | null; phone: string | null; role: string | null; linkedinUrl: string | null; tags: string[]; }
interface Organization { id: string; name: string; industry: string | null; country: string | null; website: string | null; employeeCount: number | null; }
interface EnrichmentItem { id: string; name: string; type: 'contact' | 'organization'; missing: string[]; completeness: number; }
export default function EnrichmentPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: contacts = [], isLoading: cL } = useQuery({
queryKey: ['enr-contacts', tenantId],
queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: orgs = [], isLoading: oL } = useQuery({
queryKey: ['enr-orgs', tenantId],
queryFn: () => apiFetch<Organization[]>('/v1/organizations?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const items = useMemo<EnrichmentItem[]>(() => {
const contactItems: EnrichmentItem[] = contacts.map((c) => {
const missing: string[] = [];
if (!c.email) missing.push('email');
if (!c.phone) missing.push('telefon');
if (!c.role) missing.push('funcție');
if (!c.linkedinUrl) missing.push('LinkedIn');
const completeness = Math.round(((4 - missing.length) / 4) * 100);
return { id: c.id, name: c.fullName, type: 'contact', missing, completeness };
}).filter((i) => i.completeness < 100);
const orgItems: EnrichmentItem[] = orgs.map((o) => {
const missing: string[] = [];
if (!o.industry) missing.push('industrie');
if (!o.country) missing.push('țară');
if (!o.website) missing.push('website');
if (!o.employeeCount) missing.push('angajați');
const completeness = Math.round(((4 - missing.length) / 4) * 100);
return { id: o.id, name: o.name, type: 'organization', missing, completeness };
}).filter((i) => i.completeness < 100);
return [...contactItems, ...orgItems].sort((a, b) => a.completeness - b.completeness);
}, [contacts, orgs]);
const avgContact = useMemo(() => {
if (!contacts.length) return 0;
const scores = contacts.map((c) => {
let filled = 0;
if (c.email) filled++;
if (c.phone) filled++;
if (c.role) filled++;
if (c.linkedinUrl) filled++;
return (filled / 4) * 100;
});
return Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);
}, [contacts]);
const avgOrg = useMemo(() => {
if (!orgs.length) return 0;
const scores = orgs.map((o) => {
let filled = 0;
if (o.industry) filled++;
if (o.country) filled++;
if (o.website) filled++;
if (o.employeeCount) filled++;
return (filled / 4) * 100;
});
return Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);
}, [orgs]);
function clsCls(pct: number) {
if (pct >= 75) return 'text-signal-ok';
if (pct >= 50) return 'text-warn';
return '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">Enrichment Date</h1>
<p className="text-sm text-ink-faint mt-1">
{cL || oL ? 'Se calculează…' : `${items.length} entități cu date incomplete`}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="card p-4 text-center">
<p className={`text-3xl font-bold ${clsCls(avgContact)}`}>{avgContact}%</p>
<p className="text-xs text-ink-faint mt-1">Completitudine medie contacte</p>
<div className="h-1.5 rounded-full bg-muted overflow-hidden mt-2">
<div className={`h-full rounded-full ${avgContact >= 75 ? 'bg-signal-ok' : avgContact >= 50 ? 'bg-warn' : 'bg-signal-danger'}`}
style={{ width: `${avgContact}%` }} />
</div>
</div>
<div className="card p-4 text-center">
<p className={`text-3xl font-bold ${clsCls(avgOrg)}`}>{avgOrg}%</p>
<p className="text-xs text-ink-faint mt-1">Completitudine medie organizații</p>
<div className="h-1.5 rounded-full bg-muted overflow-hidden mt-2">
<div className={`h-full rounded-full ${avgOrg >= 75 ? 'bg-signal-ok' : avgOrg >= 50 ? 'bg-warn' : 'bg-signal-danger'}`}
style={{ width: `${avgOrg}%` }} />
</div>
</div>
</div>
<div className="card p-4 bg-primary/5 border-primary/20">
<p className="text-xs font-semibold text-ink mb-1">Surse de enrichment disponibile</p>
<div className="flex flex-wrap gap-2 text-[10px] text-ink-faint">
<span>📊 ClickHouse (147M+ rânduri date publice)</span>
<span>🔍 AnythingLLM (RAG pe documente interne)</span>
<span>🤖 Hermes (enrichment automat prin AI)</span>
</div>
<p className="text-[10px] text-ink-faint mt-2">
Enrichment-ul automat rulează periodic pe server. Această pagină afișează starea curentă.
</p>
</div>
{cL || oL ? (
<div className="text-center text-sm text-ink-faint py-8">Se calculează</div>
) : items.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl"></p>
<p className="text-sm text-ink-faint">Toate entitățile au date complete.</p>
</div>
) : (
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint mb-2">
Entități de îmbogățit (sortat după completitudine crescătoare)
</p>
<div className="card divide-y divide-border/50">
{items.slice(0, 50).map((item) => (
<div key={item.id} className="flex items-center gap-4 p-3">
<span className={`w-10 text-right text-xs font-bold shrink-0 ${clsCls(item.completeness)}`}>
{item.completeness}%
</span>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-ink truncate">{item.name}</p>
<div className="flex flex-wrap gap-1 mt-0.5">
{item.missing.map((m) => (
<span key={m} className="rounded bg-signal-danger/10 text-signal-danger px-1 text-[8px]">-{m}</span>
))}
</div>
</div>
<Link
href={item.type === 'contact' ? '/dashboard/people' : '/dashboard/crm'}
className="text-[10px] text-primary hover:underline shrink-0">
Editează
</Link>
</div>
))}
{items.length > 50 && (
<p className="text-xs text-center text-ink-faint p-3">și alte {items.length - 50}</p>
)}
</div>
</div>
)}
</div>
);
}