Files
leocrm/frontend/src/components/dedup/MergeDialog.tsx
T

288 lines
11 KiB
TypeScript
Raw Normal View History

/**
* MergeDialog — Merge confirmation dialog with per-field value selection.
* Shows both contacts side-by-side, lets user choose source/target/manual value
* for each mergeable field, then calls useMergeContacts().
*/
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import { AlertTriangle } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { useToast } from '@/components/ui/Toast';
import { useMergeContacts, type DuplicatePair, type DuplicateContactBrief } from '@/api/dedup';
export interface MergeDialogProps {
open: boolean;
pair: DuplicatePair | null;
onClose: () => void;
}
type FieldChoice = 'source' | 'target' | 'manual';
interface FieldConfig {
key: keyof DuplicateContactBrief;
labelKey: string;
labelFallback: string;
}
const MERGE_FIELDS: FieldConfig[] = [
{ key: 'firstname', labelKey: 'dedup.field.firstname', labelFallback: 'Vorname' },
{ key: 'surname', labelKey: 'dedup.field.surname', labelFallback: 'Nachname' },
{ key: 'email_1', labelKey: 'dedup.field.email', labelFallback: 'E-Mail' },
{ key: 'phone_1', labelKey: 'dedup.field.phone', labelFallback: 'Telefon' },
{ key: 'mailing_city', labelKey: 'dedup.field.city', labelFallback: 'Stadt' },
{ key: 'mailing_postalcode', labelKey: 'dedup.field.postalcode', labelFallback: 'PLZ' },
];
function getFieldValue(contact: DuplicateContactBrief | undefined, key: keyof DuplicateContactBrief): string {
if (!contact) return '';
const val = contact[key];
return val == null ? '' : String(val);
}
export function MergeDialog({ open, pair, onClose }: MergeDialogProps) {
const { t } = useTranslation();
const toast = useToast();
const mergeMutation = useMergeContacts();
const [choices, setChoices] = useState<Record<string, FieldChoice>>({});
const [manualValues, setManualValues] = useState<Record<string, string>>({});
const [note, setNote] = useState('');
// Reset state when dialog opens with a new pair
useEffect(() => {
if (open && pair) {
const initialChoices: Record<string, FieldChoice> = {};
const initialManual: Record<string, string> = {};
for (const field of MERGE_FIELDS) {
initialChoices[field.key] = 'target';
initialManual[field.key] = '';
}
setChoices(initialChoices);
setManualValues(initialManual);
setNote('');
}
}, [open, pair]);
const handleChoiceChange = (fieldKey: string, choice: FieldChoice) => {
setChoices((prev) => ({ ...prev, [fieldKey]: choice }));
};
const handleManualChange = (fieldKey: string, value: string) => {
setManualValues((prev) => ({ ...prev, [fieldKey]: value }));
};
// Build field_overrides from choices
const fieldOverrides = useMemo(() => {
if (!pair) return {};
const overrides: Record<string, unknown> = {};
for (const field of MERGE_FIELDS) {
const choice = choices[field.key] ?? 'target';
if (choice === 'manual') {
overrides[field.key] = manualValues[field.key] ?? '';
} else if (choice === 'source') {
overrides[field.key] = getFieldValue(pair.source_contact, field.key);
}
// 'target' means no override — surviving record keeps its value
}
return overrides;
}, [choices, manualValues, pair]);
const handleMerge = () => {
if (!pair) return;
mergeMutation.mutate(
{
source_contact_id: pair.source_contact.id,
target_contact_id: pair.target_contact.id,
field_overrides: fieldOverrides,
note: note.trim() || undefined,
},
{
onSuccess: () => {
toast.success(t('dedup.mergeSuccess', 'Kontakte wurden erfolgreich zusammengeführt.'));
onClose();
},
onError: (error: unknown) => {
const message =
error instanceof Error
? error.message
: t('dedup.mergeError', 'Fehler beim Zusammenführen der Kontakte.');
toast.error(message);
},
},
);
};
if (!pair) return null;
const sourceName =
pair.source_contact.firstname || pair.source_contact.surname
? `${pair.source_contact.firstname ?? ''} ${pair.source_contact.surname ?? ''}`.trim()
: pair.source_contact.displayname || pair.source_contact.id;
const targetName =
pair.target_contact.firstname || pair.target_contact.surname
? `${pair.target_contact.firstname ?? ''} ${pair.target_contact.surname ?? ''}`.trim()
: pair.target_contact.displayname || pair.target_contact.id;
return (
<Modal
open={open}
onClose={onClose}
title={t('dedup.mergeDialogTitle', 'Kontakte zusammenführen')}
size="xl"
>
<div className="space-y-5">
{/* Warning */}
<div className="flex items-start gap-3 rounded-md border border-warning-300 bg-warning-50 p-3">
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-warning-600" aria-hidden="true" />
<p className="text-sm text-warning-800">
{t(
'dedup.mergeWarning',
'Der Quell-Kontakt wird nach dem Merge gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.',
)}
</p>
</div>
{/* Contact names summary */}
<div className="flex items-center justify-center gap-3 text-sm text-secondary-700">
<span className="font-medium text-secondary-900">{sourceName}</span>
<span className="text-secondary-400"></span>
<span className="font-medium text-secondary-900">{targetName}</span>
</div>
{/* Field-by-field selection table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-secondary-200 text-left text-xs uppercase tracking-wide text-secondary-500">
<th className="pb-2 pr-4 font-semibold">
{t('dedup.field', 'Feld')}
</th>
<th className="pb-2 pr-4 font-semibold">
{t('dedup.sourceContact', 'Quell-Kontakt')}
</th>
<th className="pb-2 pr-4 font-semibold">
{t('dedup.targetContact', 'Ziel-Kontakt')}
</th>
<th className="pb-2 pr-4 font-semibold">
{t('dedup.manual', 'Manuell')}
</th>
</tr>
</thead>
<tbody>
{MERGE_FIELDS.map((field) => {
const sourceVal = getFieldValue(pair.source_contact, field.key);
const targetVal = getFieldValue(pair.target_contact, field.key);
const choice = choices[field.key] ?? 'target';
return (
<tr key={field.key} className="border-b border-secondary-100">
<td className="py-3 pr-4 font-medium text-secondary-700">
{t(field.labelKey, field.labelFallback)}
</td>
<td className="py-3 pr-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`field-${field.key}`}
value="source"
checked={choice === 'source'}
onChange={() => handleChoiceChange(field.key, 'source')}
className="h-4 w-4 text-primary-600 focus:ring-primary-500"
/>
<span className={clsx('text-secondary-900', !sourceVal && 'text-secondary-300')}>
{sourceVal || '—'}
</span>
</label>
</td>
<td className="py-3 pr-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={`field-${field.key}`}
value="target"
checked={choice === 'target'}
onChange={() => handleChoiceChange(field.key, 'target')}
className="h-4 w-4 text-primary-600 focus:ring-primary-500"
/>
<span className={clsx('text-secondary-900', !targetVal && 'text-secondary-300')}>
{targetVal || '—'}
</span>
</label>
</td>
<td className="py-3 pr-4">
<div className="flex items-center gap-2">
<input
type="radio"
name={`field-${field.key}`}
value="manual"
checked={choice === 'manual'}
onChange={() => handleChoiceChange(field.key, 'manual')}
className="h-4 w-4 text-primary-600 focus:ring-primary-500"
/>
<input
type="text"
value={manualValues[field.key] ?? ''}
onChange={(e) => {
handleManualChange(field.key, e.target.value);
if (choice !== 'manual') handleChoiceChange(field.key, 'manual');
}}
disabled={choice !== 'manual'}
className={clsx(
'flex-1 rounded-md border px-2 py-1 text-sm min-h-touch',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
choice === 'manual'
? 'border-primary-400 text-secondary-900'
: 'border-secondary-200 text-secondary-400 bg-secondary-50',
)}
placeholder={t('dedup.manualPlaceholder', 'Eigener Wert…')}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Note textarea */}
<div>
<label htmlFor="merge-note" className="block text-sm font-medium text-secondary-700 mb-1">
{t('dedup.note', 'Notiz (optional)')}
</label>
<textarea
id="merge-note"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
className={clsx(
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'text-secondary-900 placeholder-secondary-400 resize-y',
)}
placeholder={t('dedup.notePlaceholder', 'Grund für den Merge…')}
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={onClose} disabled={mergeMutation.isPending}>
{t('common.cancel', 'Abbrechen')}
</Button>
<Button
variant="danger"
onClick={handleMerge}
isLoading={mergeMutation.isPending}
>
{t('dedup.executeMerge', 'Merge ausführen')}
</Button>
</div>
</div>
</Modal>
);
}