288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
|
|
/**
|
||
|
|
* 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>
|
||
|
|
);
|
||
|
|
}
|