feat(CC-080): add Entity Resolution page (Levenshtein duplicate detection for contacts/orgs)
This commit is contained in:
parent
abd7a485b9
commit
e75528a681
1 changed files with 171 additions and 0 deletions
171
src/app/dashboard/data/entities/page.tsx
Normal file
171
src/app/dashboard/data/entities/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue