From e75528a681d018d877b99c0c01f444192de872dd Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 13:02:10 +0000 Subject: [PATCH] feat(CC-080): add Entity Resolution page (Levenshtein duplicate detection for contacts/orgs) --- src/app/dashboard/data/entities/page.tsx | 171 +++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 src/app/dashboard/data/entities/page.tsx diff --git a/src/app/dashboard/data/entities/page.tsx b/src/app/dashboard/data/entities/page.tsx new file mode 100644 index 0000000..90134e2 --- /dev/null +++ b/src/app/dashboard/data/entities/page.tsx @@ -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 { + 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[] { + const groups: DuplicateGroup[] = []; + const seen = new Set(); + 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[] { + const groups: DuplicateGroup[] = []; + const seen = new Set(); + 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('/v1/contacts?limit=500', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + const { data: orgs = [], isLoading: oL } = useQuery({ + queryKey: ['er-orgs', tenantId], + queryFn: () => apiFetch('/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 ( +
+
+

Rezoluție Entități

+

+ {isLoading ? 'Se calculează…' : `${contactDups.length} grupuri de contacte duplicate · ${orgDups.length} organizații similare`} +

+
+ +
+

+ Detecție prin distanță Levenshtein (≤2 caractere) și email duplicat. + Merge-ul este manual și reversibil — nu se face automat. +

+
+ +
+ {([['contacts', `Contacte (${contactDups.length})`], ['orgs', `Organizații (${orgDups.length})`]] as const).map(([key, label]) => ( + + ))} +
+ + {isLoading ? ( +
Se calculează duplicate…
+ ) : (tab === 'contacts' ? contactDups : orgDups).length === 0 ? ( +
+

+

Niciun duplicat detectat.

+
+ ) : ( +
+ {(tab === 'contacts' ? contactDups as DuplicateGroup[] : orgDups as DuplicateGroup[]).map((group) => ( +
+
+ + {group.matchReason} + + {group.items.length} înregistrări +
+
+ {group.items.map((item) => ( +
+
+ {'fullName' in item ? ( + <> +

{(item as Contact).fullName}

+ {(item as Contact).email &&

{(item as Contact).email}

} + + ) : ( +

{(item as Organization).name}

+ )} +
+ {item.id.slice(0, 8)}… +
+ ))} +
+
+ + +
+
+ ))} +
+ )} +
+ ); +}