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
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user