Task 5.23: Deduplication / Merge — ContactMergeHistory model, dedup service, API routes (duplicates/merge/merge-history), DedupDialog frontend, i18n, 5 tests
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Deduplication / merge hooks for contacts (Task 5.23).
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiPost, apiGet } from './client';
|
||||
|
||||
export interface DuplicateContactBrief {
|
||||
id: string;
|
||||
type: string;
|
||||
displayname: string;
|
||||
name: string | null;
|
||||
firstname: string | null;
|
||||
surname: string | null;
|
||||
email_1: string | null;
|
||||
email_2: string | null;
|
||||
phone_1: string | null;
|
||||
phone_2: string | null;
|
||||
mailing_city: string | null;
|
||||
mailing_postalcode: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface DuplicatePair {
|
||||
source_contact: DuplicateContactBrief;
|
||||
target_contact: DuplicateContactBrief;
|
||||
similarity_score: number;
|
||||
match_reasons: string[];
|
||||
}
|
||||
|
||||
export interface MergeHistoryItem {
|
||||
id: string;
|
||||
source_contact_id: string;
|
||||
target_contact_id: string;
|
||||
merged_fields: Record<string, unknown>;
|
||||
merged_by: string | null;
|
||||
note: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface MergeHistoryResponse {
|
||||
items: MergeHistoryItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface MergeRequest {
|
||||
source_contact_id: string;
|
||||
target_contact_id: string;
|
||||
field_overrides?: Record<string, unknown>;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export function useFindDuplicates() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (params: { threshold?: number; limit?: number }) =>
|
||||
apiPost<DuplicatePair[]>('/contacts/duplicates', {
|
||||
threshold: params.threshold ?? 0.7,
|
||||
limit: params.limit ?? 50,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['duplicates'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMergeContacts() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: MergeRequest) =>
|
||||
apiPost('/contacts/merge', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['duplicates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mergeHistory'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMergeHistory(page = 1, pageSize = 20) {
|
||||
return useQuery({
|
||||
queryKey: ['mergeHistory', page, pageSize],
|
||||
queryFn: () =>
|
||||
apiGet<MergeHistoryResponse>(`/contacts/merge-history?page=${page}&page_size=${pageSize}`),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* DedupDialog — UI for finding and merging duplicate contacts (Task 5.23).
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useFindDuplicates, useMergeContacts, type DuplicatePair } from '@/api/dedup';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { GitMerge, Search, AlertTriangle, Check } from 'lucide-react';
|
||||
|
||||
export function DedupDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { success, error: showError } = useToast();
|
||||
const findDuplicates = useFindDuplicates();
|
||||
const mergeContacts = useMergeContacts();
|
||||
|
||||
const [duplicates, setDuplicates] = useState<DuplicatePair[]>([]);
|
||||
const [selectedPair, setSelectedPair] = useState<number | null>(null);
|
||||
const [fieldOverrides, setFieldOverrides] = useState<Record<string, string>>({});
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
try {
|
||||
const result = await findDuplicates.mutateAsync({ threshold: 0.7, limit: 50 });
|
||||
setDuplicates(result || []);
|
||||
setSelectedPair(null);
|
||||
} catch {
|
||||
showError(t('dedup.searchFailed'));
|
||||
}
|
||||
}, [findDuplicates, showError, t]);
|
||||
|
||||
const handleMerge = useCallback(async () => {
|
||||
if (selectedPair === null) return;
|
||||
const pair = duplicates[selectedPair];
|
||||
if (!pair) return;
|
||||
|
||||
try {
|
||||
const overrides: Record<string, unknown> = {};
|
||||
for (const [field, value] of Object.entries(fieldOverrides)) {
|
||||
if (value === 'source') {
|
||||
overrides[field] = (pair.source_contact as Record<string, unknown>)[field];
|
||||
} else if (value === 'target') {
|
||||
overrides[field] = (pair.target_contact as Record<string, unknown>)[field];
|
||||
}
|
||||
}
|
||||
|
||||
await mergeContacts.mutateAsync({
|
||||
source_contact_id: pair.source_contact.id,
|
||||
target_contact_id: pair.target_contact.id,
|
||||
field_overrides: Object.keys(overrides).length > 0 ? overrides : undefined,
|
||||
});
|
||||
|
||||
success(t('dedup.mergeSuccess'));
|
||||
setDuplicates((prev) => prev.filter((_, i) => i !== selectedPair));
|
||||
setSelectedPair(null);
|
||||
setFieldOverrides({});
|
||||
} catch {
|
||||
showError(t('dedup.mergeFailed'));
|
||||
}
|
||||
}, [duplicates, selectedPair, fieldOverrides, mergeContacts, success, showError, t]);
|
||||
|
||||
const compareFields = [
|
||||
{ key: 'displayname', label: t('dedup.fields.displayName') },
|
||||
{ key: 'email_1', label: t('dedup.fields.email') },
|
||||
{ key: 'phone_1', label: t('dedup.fields.phone') },
|
||||
{ key: 'mailing_city', label: t('dedup.fields.city') },
|
||||
{ key: 'mailing_postalcode', label: t('dedup.fields.postalCode') },
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={t('dedup.title')} size="xl">
|
||||
<div className="space-y-4" data-testid="dedup-dialog">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Search className="w-4 h-4" />}
|
||||
onClick={handleSearch}
|
||||
isLoading={findDuplicates.isPending}
|
||||
data-testid="dedup-search-btn"
|
||||
>
|
||||
{t('dedup.findDuplicates')}
|
||||
</Button>
|
||||
{duplicates.length > 0 && (
|
||||
<Badge variant="info">{duplicates.length} {t('dedup.pairsFound')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{duplicates.length === 0 && !findDuplicates.isPending && (
|
||||
<p className="text-sm text-secondary-500" data-testid="dedup-empty">
|
||||
{t('dedup.noDuplicates')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{duplicates.map((pair, idx) => (
|
||||
<div
|
||||
key={`${pair.source_contact.id}-${pair.target_contact.id}`}
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedPair === idx ? 'border-primary-500 bg-primary-50' : 'border-secondary-200'
|
||||
}`}
|
||||
onClick={() => setSelectedPair(idx)}
|
||||
data-testid={`dedup-pair-${idx}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-warning-500" />
|
||||
<span className="font-medium">
|
||||
{t('dedup.similarity')}: {Math.round(pair.similarity_score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{pair.match_reasons.map((reason) => (
|
||||
<Badge key={reason} variant="warning">{reason}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedPair === idx && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2 text-sm font-medium text-secondary-600">
|
||||
<div>{t('dedup.field')}</div>
|
||||
<div className="text-center">{t('dedup.source')}</div>
|
||||
<div className="text-center">{t('dedup.target')}</div>
|
||||
</div>
|
||||
{compareFields.map((field) => {
|
||||
const sourceVal = (pair.source_contact as Record<string, unknown>)[field.key] as string | null;
|
||||
const targetVal = (pair.target_contact as Record<string, unknown>)[field.key] as string | null;
|
||||
return (
|
||||
<div key={field.key} className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div className="text-secondary-700">{field.label}</div>
|
||||
<div className="text-center">
|
||||
<button
|
||||
className={`px-2 py-1 rounded ${
|
||||
fieldOverrides[field.key] === 'source' ? 'bg-primary-100 text-primary-700' : 'hover:bg-secondary-100'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFieldOverrides((prev) => ({ ...prev, [field.key]: 'source' }));
|
||||
}}
|
||||
data-testid={`dedup-field-${field.key}-source`}
|
||||
>
|
||||
{sourceVal || '—'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<button
|
||||
className={`px-2 py-1 rounded ${
|
||||
fieldOverrides[field.key] === 'target' ? 'bg-primary-100 text-primary-700' : 'hover:bg-secondary-100'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFieldOverrides((prev) => ({ ...prev, [field.key]: 'target' }));
|
||||
}}
|
||||
data-testid={`dedup-field-${field.key}-target`}
|
||||
>
|
||||
{targetVal || '—'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<GitMerge className="w-4 h-4" />}
|
||||
onClick={handleMerge}
|
||||
isLoading={mergeContacts.isPending}
|
||||
data-testid="dedup-merge-btn"
|
||||
>
|
||||
{t('dedup.merge')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPair !== idx && (
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="font-medium text-secondary-800">{pair.source_contact.displayname}</div>
|
||||
<div className="text-secondary-500">{pair.source_contact.email_1 || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-secondary-800">{pair.target_contact.displayname}</div>
|
||||
<div className="text-secondary-500">{pair.target_contact.email_1 || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1032,5 +1032,26 @@
|
||||
"deleted": "Filter gelöscht",
|
||||
"load": "Filter laden",
|
||||
"noFilters": "Keine gespeicherten Filter"
|
||||
},
|
||||
"dedup": {
|
||||
"title": "Dubletten finden und zusammenführen",
|
||||
"findDuplicates": "Dubletten suchen",
|
||||
"noDuplicates": "Keine Dubletten gefunden.",
|
||||
"pairsFound": "Paare gefunden",
|
||||
"similarity": "Ähnlichkeit",
|
||||
"merge": "Zusammenführen",
|
||||
"mergeSuccess": "Kontakte wurden erfolgreich zusammengeführt.",
|
||||
"mergeFailed": "Zusammenführung fehlgeschlagen.",
|
||||
"searchFailed": "Suche nach Dubletten fehlgeschlagen.",
|
||||
"field": "Feld",
|
||||
"source": "Quelle",
|
||||
"target": "Ziel",
|
||||
"fields": {
|
||||
"displayName": "Anzeigename",
|
||||
"email": "E-Mail",
|
||||
"phone": "Telefon",
|
||||
"city": "Stadt",
|
||||
"postalCode": "PLZ"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1032,5 +1032,26 @@
|
||||
"deleted": "Filter deleted",
|
||||
"load": "Load Filter",
|
||||
"noFilters": "No saved filters"
|
||||
},
|
||||
"dedup": {
|
||||
"title": "Find and Merge Duplicates",
|
||||
"findDuplicates": "Find Duplicates",
|
||||
"noDuplicates": "No duplicates found.",
|
||||
"pairsFound": "pairs found",
|
||||
"similarity": "Similarity",
|
||||
"merge": "Merge",
|
||||
"mergeSuccess": "Contacts merged successfully.",
|
||||
"mergeFailed": "Merge failed.",
|
||||
"searchFailed": "Failed to search for duplicates.",
|
||||
"field": "Field",
|
||||
"source": "Source",
|
||||
"target": "Target",
|
||||
"fields": {
|
||||
"displayName": "Display Name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"city": "City",
|
||||
"postalCode": "Postal Code"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user