a3a5a10514
- Workflows UI: full page with definitions/instances tabs, step editor, instance detail with approve/reject - Dedup/Merge UI: duplicate detection, side-by-side comparison, field-level merge dialog, merge history - Import/Export UI: import wizard (dry-run preview), export panel (CSV/XLSX), backend export route added - Print/PDF: PrintButton component, print.css, integrated in Contacts/Calendar/Reports/ContactDetail - Backend: GET /api/v1/export endpoint, export_companies_csv() service function - Routes: /workflows, /contacts/dedup, /import-export registered - Menu items: Workflows, Import/Export, Duplikate added to automation plugin manifest - IMPLEMENTATION_PLAN.md: audit-corrected plan for all 14 remaining features
226 lines
8.3 KiB
TypeScript
226 lines
8.3 KiB
TypeScript
/**
|
||
* DedupMerge page — Main deduplication & merge UI.
|
||
* Search for duplicate contacts, review pairs, merge with field-level control.
|
||
*/
|
||
|
||
import React, { useState, useCallback } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { Search, CopyCheck, Loader2, AlertCircle } from 'lucide-react';
|
||
import { Button } from '@/components/ui/Button';
|
||
import { Input } from '@/components/ui/Input';
|
||
import { EmptyState } from '@/components/ui/EmptyState';
|
||
import { DuplicatePairCard } from '@/components/dedup/DuplicatePairCard';
|
||
import { MergeDialog } from '@/components/dedup/MergeDialog';
|
||
import { MergeHistory } from '@/components/dedup/MergeHistory';
|
||
import { useFindDuplicates, type DuplicatePair } from '@/api/dedup';
|
||
|
||
export function DedupMergePage() {
|
||
const { t } = useTranslation();
|
||
|
||
const [threshold, setThreshold] = useState(0.7);
|
||
const [limit, setLimit] = useState(50);
|
||
const [hasSearched, setHasSearched] = useState(false);
|
||
const [mergePair, setMergePair] = useState<DuplicatePair | null>(null);
|
||
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
|
||
|
||
const findDuplicates = useFindDuplicates();
|
||
const duplicates = findDuplicates.data ?? [];
|
||
|
||
const handleSearch = useCallback(() => {
|
||
setHasSearched(true);
|
||
findDuplicates.mutate({ threshold, limit });
|
||
}, [findDuplicates, threshold, limit]);
|
||
|
||
const handleMergeClick = useCallback((pair: DuplicatePair) => {
|
||
setMergePair(pair);
|
||
setMergeDialogOpen(true);
|
||
}, []);
|
||
|
||
const handleMergeDialogClose = useCallback(() => {
|
||
setMergeDialogOpen(false);
|
||
setMergePair(null);
|
||
}, []);
|
||
|
||
const handleLimitChange = useCallback(
|
||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const val = parseInt(e.target.value, 10);
|
||
if (isNaN(val) || val < 1) {
|
||
setLimit(1);
|
||
} else if (val > 500) {
|
||
setLimit(500);
|
||
} else {
|
||
setLimit(val);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
return (
|
||
<div className="flex h-full flex-col overflow-y-auto">
|
||
<div className="mx-auto w-full max-w-5xl space-y-6 p-6">
|
||
{/* Header section */}
|
||
<div className="rounded-lg border border-secondary-200 bg-white shadow-sm">
|
||
<div className="border-b border-secondary-200 px-6 py-4">
|
||
<h1 className="text-xl font-bold text-secondary-900">
|
||
{t('dedup.title', 'Duplikate & Zusammenführen')}
|
||
</h1>
|
||
<p className="mt-1 text-sm text-secondary-500">
|
||
{t(
|
||
'dedup.description',
|
||
'Suchen Sie nach dubletten Kontakten und führen Sie diese zusammen.',
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-5 px-6 py-5">
|
||
{/* Threshold slider */}
|
||
<div>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<label htmlFor="threshold-slider" className="text-sm font-medium text-secondary-700">
|
||
{t('dedup.threshold', 'Ähnlichkeitsschwelle')}
|
||
</label>
|
||
<span className="text-sm font-semibold text-primary-700">
|
||
{Math.round(threshold * 100)}%
|
||
</span>
|
||
</div>
|
||
<input
|
||
id="threshold-slider"
|
||
type="range"
|
||
min={0.5}
|
||
max={1.0}
|
||
step={0.05}
|
||
value={threshold}
|
||
onChange={(e) => setThreshold(parseFloat(e.target.value))}
|
||
className="w-full h-2 rounded-lg appearance-none cursor-pointer bg-secondary-200 accent-primary-600"
|
||
/>
|
||
<div className="mt-1 flex justify-between text-xs text-secondary-400">
|
||
<span>50%</span>
|
||
<span>100%</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Limit input + search button */}
|
||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||
<div className="w-full sm:w-40">
|
||
<Input
|
||
type="number"
|
||
label={t('dedup.limit', 'Max. Ergebnisse')}
|
||
value={limit}
|
||
onChange={handleLimitChange}
|
||
min={1}
|
||
max={500}
|
||
helperText={t('dedup.limitHelper', '1–500')}
|
||
/>
|
||
</div>
|
||
<div className="flex-shrink-0">
|
||
<Button
|
||
variant="primary"
|
||
size="md"
|
||
isLoading={findDuplicates.isPending}
|
||
icon={<Search className="h-4 w-4" />}
|
||
onClick={handleSearch}
|
||
>
|
||
{t('dedup.search', 'Duplikate suchen')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Results section */}
|
||
<div className="space-y-4">
|
||
{/* Loading state */}
|
||
{findDuplicates.isPending && (
|
||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||
<Loader2 className="h-8 w-8 animate-spin text-primary-500" aria-hidden="true" />
|
||
<p className="mt-3 text-sm text-secondary-500">
|
||
{t('dedup.searching', 'Suche nach Duplikaten…')}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Error state */}
|
||
{findDuplicates.isError && (
|
||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||
<AlertCircle className="h-8 w-8 text-danger-500" aria-hidden="true" />
|
||
<p className="mt-3 text-sm font-medium text-danger-700">
|
||
{t('dedup.searchError', 'Fehler bei der Duplikatssuche.')}
|
||
</p>
|
||
<p className="mt-1 text-xs text-secondary-400">
|
||
{findDuplicates.error instanceof Error
|
||
? findDuplicates.error.message
|
||
: t('common.unknownError', 'Unbekannter Fehler')}
|
||
</p>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
className="mt-4"
|
||
onClick={handleSearch}
|
||
>
|
||
{t('common.retry', 'Erneut versuchen')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Empty state: no search yet */}
|
||
{!findDuplicates.isPending && !findDuplicates.isError && !hasSearched && (
|
||
<EmptyState
|
||
icon={<CopyCheck className="h-12 w-12" />}
|
||
title={t('dedup.emptyTitle', 'Keine Suche durchgeführt')}
|
||
description={t(
|
||
'dedup.emptyDescription',
|
||
'Stellen Sie die Schwellwerte ein und klicken Sie auf „Duplikate suchen“, um mögliche Dubletten zu finden.',
|
||
)}
|
||
/>
|
||
)}
|
||
|
||
{/* Empty results: search done, no duplicates found */}
|
||
{!findDuplicates.isPending && !findDuplicates.isError && hasSearched && duplicates.length === 0 && (
|
||
<EmptyState
|
||
icon={<CopyCheck className="h-12 w-12" />}
|
||
title={t('dedup.noDuplicates', 'Keine Duplikate gefunden')}
|
||
description={t(
|
||
'dedup.noDuplicatesDescription',
|
||
'Mit den aktuellen Einstellungen wurden keine Duplikate gefunden. Versuchen Sie eine niedrigere Schwellwert.',
|
||
)}
|
||
/>
|
||
)}
|
||
|
||
{/* Results list */}
|
||
{!findDuplicates.isPending && !findDuplicates.isError && duplicates.length > 0 && (
|
||
<>
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold text-secondary-900">
|
||
{t('dedup.results', 'Gefundene Duplikate')}
|
||
</h2>
|
||
<span className="rounded-full bg-secondary-100 px-3 py-0.5 text-sm font-medium text-secondary-600">
|
||
{duplicates.length}
|
||
</span>
|
||
</div>
|
||
<div className="space-y-4">
|
||
{duplicates.map((pair, idx) => (
|
||
<DuplicatePairCard
|
||
key={`${pair.source_contact.id}-${pair.target_contact.id}-${idx}`}
|
||
pair={pair}
|
||
onMerge={handleMergeClick}
|
||
/>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Merge History section */}
|
||
<MergeHistory />
|
||
</div>
|
||
|
||
{/* Merge Dialog */}
|
||
<MergeDialog
|
||
open={mergeDialogOpen}
|
||
pair={mergePair}
|
||
onClose={handleMergeDialogClose}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|