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:
Agent Zero
2026-07-26 02:35:44 +02:00
parent 6d484ed747
commit a3a5a10514
27 changed files with 4288 additions and 5 deletions
@@ -0,0 +1,114 @@
/**
* DuplicatePairCard — Side-by-side comparison of a duplicate pair.
* Shows both contacts with key fields, similarity score badge, match reasons,
* and a "Zusammenführen" button.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import { GitMerge, Mail, Phone, MapPin, Calendar, User } from 'lucide-react';
import { Badge, type BadgeVariant } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import type { DuplicatePair, DuplicateContactBrief } from '@/api/dedup';
export interface DuplicatePairCardProps {
pair: DuplicatePair;
onMerge: (pair: DuplicatePair) => void;
}
function similarityVariant(score: number): BadgeVariant {
const pct = score * 100;
if (pct > 90) return 'success';
if (pct > 75) return 'warning';
return 'danger';
}
function ContactColumn({ contact, label }: { contact: DuplicateContactBrief; label: string }) {
const { t } = useTranslation();
const fullName =
contact.firstname || contact.surname
? `${contact.firstname ?? ''} ${contact.surname ?? ''}`.trim()
: contact.displayname || contact.name || '—';
const rows = [
{ icon: User, label: t('dedup.field.name', 'Name'), value: fullName },
{ icon: Mail, label: t('dedup.field.email', 'E-Mail'), value: contact.email_1 },
{ icon: Phone, label: t('dedup.field.phone', 'Telefon'), value: contact.phone_1 },
{ icon: MapPin, label: t('dedup.field.city', 'Stadt'), value: contact.mailing_city },
{ icon: MapPin, label: t('dedup.field.postalcode', 'PLZ'), value: contact.mailing_postalcode },
{ icon: Calendar, label: t('dedup.field.created', 'Erstellt'), value: contact.created_at },
];
return (
<div className="flex-1 min-w-0 rounded-md border border-secondary-200 bg-secondary-50 p-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-secondary-500">{label}</p>
<dl className="space-y-2">
{rows.map((row) => {
const Icon = row.icon;
return (
<div key={row.label} className="flex items-start gap-2">
<Icon className="mt-0.5 h-4 w-4 flex-shrink-0 text-secondary-400" aria-hidden="true" />
<div className="min-w-0">
<dt className="text-xs text-secondary-500">{row.label}</dt>
<dd className={clsx('text-sm text-secondary-900 truncate', !row.value && 'text-secondary-300')}>
{row.value || '—'}
</dd>
</div>
</div>
);
})}
</dl>
</div>
);
}
export function DuplicatePairCard({ pair, onMerge }: DuplicatePairCardProps) {
const { t } = useTranslation();
const pct = Math.round(pair.similarity_score * 100);
return (
<div className="rounded-lg border border-secondary-200 bg-white shadow-sm">
{/* Header: score + reasons + merge button */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-secondary-200 px-5 py-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={similarityVariant(pair.similarity_score)} className="text-sm font-bold">
{pct}%
</Badge>
<span className="text-sm text-secondary-600">
{t('dedup.similarity', 'Ähnlichkeit')}
</span>
{pair.match_reasons.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{pair.match_reasons.map((reason) => (
<Badge key={reason} variant="secondary">
{reason}
</Badge>
))}
</div>
)}
</div>
<Button
size="sm"
variant="primary"
icon={<GitMerge className="h-4 w-4" />}
onClick={() => onMerge(pair)}
>
{t('dedup.merge', 'Zusammenführen')}
</Button>
</div>
{/* Body: side-by-side contacts */}
<div className="flex flex-col gap-4 p-5 md:flex-row">
<ContactColumn
contact={pair.source_contact}
label={t('dedup.sourceContact', 'Quell-Kontakt')}
/>
<ContactColumn
contact={pair.target_contact}
label={t('dedup.targetContact', 'Ziel-Kontakt')}
/>
</div>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,176 @@
/**
* MergeHistory — Collapsible list of past merge operations.
* Uses useMergeHistory() hook internally.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDown, ChevronRight, History, GitMerge } from 'lucide-react';
import { useMergeHistory, type MergeHistoryItem } from '@/api/dedup';
function formatDate(iso: string | null): string {
if (!iso) return '—';
try {
const d = new Date(iso);
return d.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
}
function formatFieldName(key: string): string {
const map: Record<string, string> = {
firstname: 'Vorname',
surname: 'Nachname',
email_1: 'E-Mail',
phone_1: 'Telefon',
mailing_city: 'Stadt',
mailing_postalcode: 'PLZ',
};
return map[key] ?? key;
}
function MergeHistoryRow({ item }: { item: MergeHistoryItem }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const mergedFields = item.merged_fields;
const fieldEntries = Object.entries(mergedFields || {});
return (
<div className="border-b border-secondary-100 last:border-b-0">
<button
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-3 py-3 text-left hover:bg-secondary-50 px-3 rounded-md transition-colors"
>
{expanded ? (
<ChevronDown className="h-4 w-4 flex-shrink-0 text-secondary-400" aria-hidden="true" />
) : (
<ChevronRight className="h-4 w-4 flex-shrink-0 text-secondary-400" aria-hidden="true" />
)}
<GitMerge className="h-4 w-4 flex-shrink-0 text-secondary-400" aria-hidden="true" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm text-secondary-900">
<span className="font-medium">
{t('dedup.history.source', 'Quelle')}: {item.source_contact_id.slice(0, 8)}
</span>
<span className="text-secondary-400"></span>
<span className="font-medium">
{t('dedup.history.target', 'Ziel')}: {item.target_contact_id.slice(0, 8)}
</span>
</div>
<div className="mt-0.5 flex items-center gap-3 text-xs text-secondary-500">
<span>{formatDate(item.created_at)}</span>
{item.merged_by && (
<span>
{t('dedup.history.mergedBy', 'durch')}: {item.merged_by}
</span>
)}
{item.note && (
<span className="truncate">{item.note}</span>
)}
</div>
</div>
</button>
{expanded && fieldEntries.length > 0 && (
<div className="px-10 pb-3">
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-secondary-500">
{t('dedup.history.mergedFields', 'Überschriebene Felder')}:
</p>
<ul className="space-y-0.5">
{fieldEntries.map(([key, val]) => (
<li key={key} className="text-sm text-secondary-700">
<span className="font-medium">{formatFieldName(key)}:</span>{' '}
{val == null ? '—' : String(val)}
</li>
))}
</ul>
</div>
)}
{expanded && fieldEntries.length === 0 && (
<div className="px-10 pb-3 text-sm text-secondary-400">
{t('dedup.history.noOverrides', 'Keine Feld-Überschreibungen.')}
</div>
)}
</div>
);
}
export function MergeHistory() {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading, isError, error } = useMergeHistory(1, 20);
const items = data?.items ?? [];
const total = data?.total ?? 0;
return (
<div className="rounded-lg border border-secondary-200 bg-white shadow-sm">
<button
onClick={() => setIsOpen((v) => !v)}
className="flex w-full items-center justify-between px-5 py-3 text-left"
>
<div className="flex items-center gap-2">
<History className="h-5 w-5 text-secondary-400" aria-hidden="true" />
<h3 className="text-lg font-semibold text-secondary-900">
{t('dedup.history.title', 'Merge-Historie')}
</h3>
{total > 0 && (
<span className="ml-1 rounded-full bg-secondary-100 px-2 py-0.5 text-xs font-medium text-secondary-600">
{total}
</span>
)}
</div>
{isOpen ? (
<ChevronDown className="h-5 w-5 text-secondary-400" aria-hidden="true" />
) : (
<ChevronRight className="h-5 w-5 text-secondary-400" aria-hidden="true" />
)}
</button>
{isOpen && (
<div className="border-t border-secondary-200 px-3 py-2">
{isLoading && (
<div className="py-8 text-center text-sm text-secondary-500">
{t('common.loading', 'Laden…')}
</div>
)}
{isError && (
<div className="py-8 text-center text-sm text-danger-600">
{t('dedup.history.loadError', 'Fehler beim Laden der Historie.')}
{error instanceof Error && (
<span className="block mt-1 text-xs text-secondary-400">{error.message}</span>
)}
</div>
)}
{!isLoading && !isError && items.length === 0 && (
<div className="py-8 text-center text-sm text-secondary-500">
{t('dedup.history.empty', 'Noch keine Merges durchgeführt.')}
</div>
)}
{!isLoading && !isError && items.length > 0 && (
<div>
{items.map((item) => (
<MergeHistoryRow key={item.id} item={item} />
))}
{total > items.length && (
<p className="mt-2 px-3 py-2 text-center text-xs text-secondary-400">
{t('dedup.history.more', 'Weitere Einträge auf Seite 2+')}
</p>
)}
</div>
)}
</div>
)}
</div>
);
}