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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user