Files
leocrm/frontend/src/pages/DedupMerge.tsx
T
Agent Zero a3a5a10514 Phase 1: Workflows UI, Dedup/Merge UI, Import/Export UI, Print/PDF
- 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
2026-07-26 02:35:44 +02:00

226 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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', '1500')}
/>
</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>
);
}