feat(CC-080): add Entity Resolution page (Levenshtein duplicate detection for contacts/orgs)

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

View file

@ -0,0 +1,171 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
interface Contact { id: string; fullName: string; email: string | null; phone: string | null; tags: string[]; }
interface Organization { id: string; name: string; industry: string | null; country: string | null; }
interface DuplicateGroup<T> {
key: string; items: T[]; matchReason: string;
}
function levenshtein(a: string, b: string): number {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++)
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
return dp[m][n];
}
function normalize(s: string) { return s.toLowerCase().replace(/[^a-z0-9]/g, ''); }
function findDuplicateContacts(contacts: Contact[]): DuplicateGroup<Contact>[] {
const groups: DuplicateGroup<Contact>[] = [];
const seen = new Set<string>();
for (let i = 0; i < contacts.length; i++) {
if (seen.has(contacts[i].id)) continue;
const group: Contact[] = [contacts[i]];
const normA = normalize(contacts[i].fullName);
for (let j = i + 1; j < contacts.length; j++) {
if (seen.has(contacts[j].id)) continue;
const normB = normalize(contacts[j].fullName);
const dist = levenshtein(normA, normB);
const similar = dist <= 2 && Math.abs(normA.length - normB.length) <= 3;
const sameEmail = contacts[i].email && contacts[i].email === contacts[j].email;
if (similar || sameEmail) {
group.push(contacts[j]);
seen.add(contacts[j].id);
}
}
if (group.length > 1) {
seen.add(contacts[i].id);
const sameEmail = group.filter((c) => c.email).map((c) => c.email);
const emailDup = new Set(sameEmail).size < sameEmail.length;
groups.push({ key: contacts[i].id, items: group, matchReason: emailDup ? 'Email duplicat' : 'Nume similar' });
}
}
return groups.slice(0, 50);
}
function findDuplicateOrgs(orgs: Organization[]): DuplicateGroup<Organization>[] {
const groups: DuplicateGroup<Organization>[] = [];
const seen = new Set<string>();
for (let i = 0; i < orgs.length; i++) {
if (seen.has(orgs[i].id)) continue;
const group: Organization[] = [orgs[i]];
const normA = normalize(orgs[i].name);
for (let j = i + 1; j < orgs.length; j++) {
if (seen.has(orgs[j].id)) continue;
const normB = normalize(orgs[j].name);
const dist = levenshtein(normA, normB);
if (dist <= 3 && Math.abs(normA.length - normB.length) <= 4) {
group.push(orgs[j]);
seen.add(orgs[j].id);
}
}
if (group.length > 1) {
seen.add(orgs[i].id);
groups.push({ key: orgs[i].id, items: group, matchReason: 'Nume similar' });
}
}
return groups.slice(0, 20);
}
export default function EntityResolutionPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [tab, setTab] = useState<'contacts' | 'orgs'>('contacts');
const { data: contacts = [], isLoading: cL } = useQuery({
queryKey: ['er-contacts', tenantId],
queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: orgs = [], isLoading: oL } = useQuery({
queryKey: ['er-orgs', tenantId],
queryFn: () => apiFetch<Organization[]>('/v1/organizations?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const contactDups = useMemo(() => findDuplicateContacts(contacts), [contacts]);
const orgDups = useMemo(() => findDuplicateOrgs(orgs), [orgs]);
const isLoading = cL || oL;
return (
<div className="max-w-4xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Rezoluție Entități</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se calculează…' : `${contactDups.length} grupuri de contacte duplicate · ${orgDups.length} organizații similare`}
</p>
</div>
<div className="card p-4 bg-primary/5 border-primary/20">
<p className="text-xs text-ink">
Detecție prin distanță Levenshtein (2 caractere) și email duplicat.
<strong> Merge-ul este manual și reversibil</strong> nu se face automat.
</p>
</div>
<div className="flex gap-1 border-b border-border/50">
{([['contacts', `Contacte (${contactDups.length})`], ['orgs', `Organizații (${orgDups.length})`]] as const).map(([key, label]) => (
<button key={key} onClick={() => setTab(key)}
className={`pb-2 px-3 text-sm font-medium border-b-2 transition-colors ${tab === key ? 'border-primary text-ink' : 'border-transparent text-ink-faint hover:text-ink'}`}>
{label}
</button>
))}
</div>
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se calculează duplicate</div>
) : (tab === 'contacts' ? contactDups : orgDups).length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl"></p>
<p className="text-sm text-ink-faint">Niciun duplicat detectat.</p>
</div>
) : (
<div className="space-y-3">
{(tab === 'contacts' ? contactDups as DuplicateGroup<Contact | Organization>[] : orgDups as DuplicateGroup<Contact | Organization>[]).map((group) => (
<div key={group.key} className="card p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="rounded-full bg-warn/10 text-warn px-2 py-0.5 text-[9px] font-bold">
{group.matchReason}
</span>
<span className="text-[10px] text-ink-faint">{group.items.length} înregistrări</span>
</div>
<div className="space-y-1">
{group.items.map((item) => (
<div key={item.id} className="flex items-center gap-2 rounded bg-muted/50 px-3 py-2">
<div className="flex-1 min-w-0">
{'fullName' in item ? (
<>
<p className="text-xs font-medium text-ink">{(item as Contact).fullName}</p>
{(item as Contact).email && <p className="text-[10px] text-ink-faint">{(item as Contact).email}</p>}
</>
) : (
<p className="text-xs font-medium text-ink">{(item as Organization).name}</p>
)}
</div>
<span className="text-[9px] text-ink-faint font-mono">{item.id.slice(0, 8)}</span>
</div>
))}
</div>
<div className="flex gap-2 pt-1">
<button className="rounded border px-3 py-1 text-xs text-ink hover:bg-muted/50" title="Marchează ca duplicate verificat manual">
Confirmat duplicat
</button>
<button className="rounded border px-3 py-1 text-xs text-ink-faint hover:bg-muted/50" title="Marchează ca entități diferite">
Nu sunt duplicate
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}