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,137 @@
|
||||
/**
|
||||
* PrintButton — reusable button with dropdown for Print and PDF export.
|
||||
* Uses lucide-react icons and clsx for styling.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { Printer, FileDown, ChevronDown } from 'lucide-react';
|
||||
import { printElement, printCurrentPage, exportToPDF } from '@/utils/print';
|
||||
|
||||
export interface PrintButtonProps {
|
||||
/** Element id to print. If omitted, prints the whole page. */
|
||||
targetId?: string;
|
||||
/** Filename suggestion for PDF export. */
|
||||
filename?: string;
|
||||
/** Additional Tailwind classes for the trigger button. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PrintButton({
|
||||
targetId,
|
||||
filename = 'export',
|
||||
className,
|
||||
}: PrintButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handlePrint = useCallback(() => {
|
||||
setOpen(false);
|
||||
if (targetId) {
|
||||
printElement(targetId);
|
||||
} else {
|
||||
printCurrentPage();
|
||||
}
|
||||
}, [targetId]);
|
||||
|
||||
const handleExportPDF = useCallback(() => {
|
||||
setOpen(false);
|
||||
if (targetId) {
|
||||
exportToPDF(targetId, filename);
|
||||
} else {
|
||||
// No specific element — use whole-page print which user can save as PDF
|
||||
printCurrentPage();
|
||||
}
|
||||
}, [targetId, filename]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative inline-block" data-no-print>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md',
|
||||
'text-sm font-medium text-secondary-700',
|
||||
'border border-secondary-300 bg-white',
|
||||
'hover:bg-secondary-50 transition-colors',
|
||||
'min-h-touch',
|
||||
open && 'ring-2 ring-primary-500 ring-offset-1',
|
||||
className,
|
||||
)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label="Drucken oder als PDF exportieren"
|
||||
title="Drucken / PDF"
|
||||
data-testid="print-button-trigger"
|
||||
>
|
||||
<Printer className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
<span className="hidden sm:inline">Drucken</span>
|
||||
<ChevronDown
|
||||
className={clsx('w-3.5 h-3.5 transition-transform', open && 'rotate-180')}
|
||||
aria-hidden="true"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className={clsx(
|
||||
'absolute right-0 mt-1 z-50',
|
||||
'min-w-[180px] rounded-md border border-secondary-200',
|
||||
'bg-white shadow-lg py-1',
|
||||
)}
|
||||
data-testid="print-button-dropdown"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrint}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 w-full px-3 py-2',
|
||||
'text-sm text-secondary-700 hover:bg-secondary-50',
|
||||
'transition-colors text-left',
|
||||
)}
|
||||
role="menuitem"
|
||||
data-testid="print-button-print"
|
||||
>
|
||||
<Printer className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
|
||||
<span>Drucken</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportPDF}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 w-full px-3 py-2',
|
||||
'text-sm text-secondary-700 hover:bg-secondary-50',
|
||||
'transition-colors text-left',
|
||||
)}
|
||||
role="menuitem"
|
||||
data-testid="print-button-pdf"
|
||||
>
|
||||
<FileDown className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
|
||||
<span>Als PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, Loader2, FileText, CheckCircle } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { exportData } from '@/api/importExport';
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const ENTITY_OPTIONS = [
|
||||
{ value: 'contacts', label: 'Kontakte' },
|
||||
{ value: 'companies', label: 'Firmen' },
|
||||
];
|
||||
|
||||
const FORMAT_OPTIONS = [
|
||||
{ value: 'csv', label: 'CSV' },
|
||||
{ value: 'xlsx', label: 'XLSX' },
|
||||
];
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function ExportPanel() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
|
||||
const [entityType, setEntityType] = useState<string>('contacts');
|
||||
const [format, setFormat] = useState<string>('csv');
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [lastExport, setLastExport] = useState<string | null>(null);
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
await exportData(entityType, format);
|
||||
const timestamp = new Date().toLocaleString();
|
||||
setLastExport(timestamp);
|
||||
toast.success(
|
||||
t('importExport.exportSuccess', 'Export erfolgreich heruntergeladen')
|
||||
);
|
||||
} catch (err: any) {
|
||||
toast.error(
|
||||
err?.message || t('importExport.exportFailed', 'Export fehlgeschlagen')
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t('importExport.exportTitle', 'Daten exportieren')}
|
||||
description={t('importExport.exportDescription', 'Exportieren Sie Kontakte oder Firmen als CSV- oder XLSX-Datei')}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Entity type */}
|
||||
<Select
|
||||
label={t('importExport.entityType', 'Entitätstyp')}
|
||||
options={ENTITY_OPTIONS}
|
||||
value={entityType}
|
||||
onChange={(e) => setEntityType(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Format */}
|
||||
<Select
|
||||
label={t('importExport.format', 'Format')}
|
||||
options={FORMAT_OPTIONS}
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Preview info */}
|
||||
<div className="bg-secondary-50 rounded-lg p-4 flex items-start gap-3">
|
||||
<FileText className="w-5 h-5 text-secondary-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-secondary-600 space-y-1">
|
||||
<p>
|
||||
{t('importExport.exportSummary', 'Exportiert alle')}{' '}
|
||||
<span className="font-medium text-secondary-900">
|
||||
{entityType === 'contacts' ? 'Kontakte' : 'Firmen'}
|
||||
</span>{' '}
|
||||
als{' '}
|
||||
<span className="font-medium text-secondary-900">
|
||||
{format.toUpperCase()}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500">
|
||||
{t('importExport.exportHint', 'Die Datei wird nach dem Klick auf "Exportieren" heruntergeladen')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last export */}
|
||||
{lastExport && (
|
||||
<div className="flex items-center gap-2 text-sm text-success-700">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>
|
||||
{t('importExport.lastExport', 'Letzter Export')}: {lastExport}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
isLoading={isExporting}
|
||||
icon={!isExporting ? <Download className="w-4 h-4" /> : undefined}
|
||||
>
|
||||
{t('importExport.export', 'Exportieren')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Loader2,
|
||||
Upload,
|
||||
RotateCcw,
|
||||
ArrowLeft,
|
||||
Eye,
|
||||
Play,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { importCsv, type ImportResult } from '@/api/importExport';
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
const ACCEPTED_EXTENSIONS = ['.csv'];
|
||||
|
||||
const ENTITY_OPTIONS = [
|
||||
{ value: 'contacts', label: 'Kontakte' },
|
||||
{ value: 'companies', label: 'Firmen' },
|
||||
];
|
||||
|
||||
type WizardStep = 'upload' | 'preview' | 'review' | 'result';
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function ImportWizard() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
|
||||
const [step, setStep] = useState<WizardStep>('upload');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [entityType, setEntityType] = useState<string>('contacts');
|
||||
const [previewResult, setPreviewResult] = useState<ImportResult | null>(null);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const [previewConfirmed, setPreviewConfirmed] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
||||
const [isLoadingImport, setIsLoadingImport] = useState(false);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ─── File validation ────────────────────────────────────────────────────
|
||||
|
||||
const validateFile = useCallback((f: File): string | null => {
|
||||
const ext = f.name.toLowerCase().substring(f.name.lastIndexOf('.'));
|
||||
if (!ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||
return t('importExport.onlyCsvAllowed', 'Nur CSV-Dateien sind erlaubt');
|
||||
}
|
||||
if (f.size > MAX_FILE_SIZE) {
|
||||
return t('importExport.fileTooLarge', 'Datei ist größer als 10 MB');
|
||||
}
|
||||
return null;
|
||||
}, [t]);
|
||||
|
||||
const handleFileSelect = useCallback((f: File | null) => {
|
||||
if (!f) return;
|
||||
const error = validateFile(f);
|
||||
if (error) {
|
||||
setFileError(error);
|
||||
setFile(null);
|
||||
return;
|
||||
}
|
||||
setFileError(null);
|
||||
setFile(f);
|
||||
}, [validateFile]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile) handleFileSelect(droppedFile);
|
||||
}, [handleFileSelect]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files?.[0] ?? null;
|
||||
handleFileSelect(selected);
|
||||
}, [handleFileSelect]);
|
||||
|
||||
// ─── Preview ────────────────────────────────────────────────────────────
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!file) return;
|
||||
setIsLoadingPreview(true);
|
||||
try {
|
||||
const result = await importCsv(file, entityType, true);
|
||||
setPreviewResult(result);
|
||||
setStep('preview');
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || t('importExport.previewFailed', 'Vorschau fehlgeschlagen'));
|
||||
} finally {
|
||||
setIsLoadingPreview(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Import ─────────────────────────────────────────────────────────────
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) return;
|
||||
setIsLoadingImport(true);
|
||||
try {
|
||||
const result = await importCsv(file, entityType, false);
|
||||
setImportResult(result);
|
||||
setStep('result');
|
||||
toast.success(t('importExport.importSuccess', 'Import erfolgreich abgeschlossen'));
|
||||
} catch (err: any) {
|
||||
setImportResult({
|
||||
errors: [err?.message || t('importExport.importFailed', 'Import fehlgeschlagen')],
|
||||
});
|
||||
setStep('result');
|
||||
} finally {
|
||||
setIsLoadingImport(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Reset ──────────────────────────────────────────────────────────────
|
||||
|
||||
const handleReset = () => {
|
||||
setStep('upload');
|
||||
setFile(null);
|
||||
setPreviewResult(null);
|
||||
setImportResult(null);
|
||||
setPreviewConfirmed(false);
|
||||
setFileError(null);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (step === 'preview') setStep('upload');
|
||||
else if (step === 'review') setStep('preview');
|
||||
};
|
||||
|
||||
// ─── Step indicator ─────────────────────────────────────────────────────
|
||||
|
||||
const steps: { key: WizardStep; label: string }[] = [
|
||||
{ key: 'upload', label: t('importExport.stepUpload', 'Datei hochladen') },
|
||||
{ key: 'preview', label: t('importExport.stepPreview', 'Vorschau') },
|
||||
{ key: 'review', label: t('importExport.stepReview', 'Überprüfung') },
|
||||
{ key: 'result', label: t('importExport.stepResult', 'Ergebnis') },
|
||||
];
|
||||
const currentStepIndex = steps.findIndex((s) => s.key === step);
|
||||
|
||||
// ─── Render ─────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-between max-w-2xl">
|
||||
{steps.map((s, idx) => (
|
||||
<React.Fragment key={s.key}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={clsx(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium transition-colors',
|
||||
idx <= currentStepIndex
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-secondary-200 text-secondary-500'
|
||||
)}
|
||||
>
|
||||
{idx < currentStepIndex ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : (
|
||||
idx + 1
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-sm font-medium hidden sm:inline',
|
||||
idx <= currentStepIndex ? 'text-secondary-900' : 'text-secondary-400'
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{idx < steps.length - 1 && (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-1 h-0.5 mx-2 transition-colors',
|
||||
idx < currentStepIndex ? 'bg-primary-600' : 'bg-secondary-200'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step: Upload */}
|
||||
{step === 'upload' && (
|
||||
<Card
|
||||
title={t('importExport.uploadTitle', 'Datei hochladen')}
|
||||
description={t('importExport.uploadDescription', 'Wählen Sie eine CSV-Datei und den Entitätstyp für den Import')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Entity type */}
|
||||
<Select
|
||||
label={t('importExport.entityType', 'Entitätstyp')}
|
||||
options={ENTITY_OPTIONS}
|
||||
value={entityType}
|
||||
onChange={(e) => setEntityType(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Drag & drop zone */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={clsx(
|
||||
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
|
||||
isDragOver
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-secondary-300 hover:border-secondary-400 bg-secondary-50'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
/>
|
||||
{file ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileText className="w-10 h-10 text-primary-600" />
|
||||
<p className="text-sm font-medium text-secondary-900">{file.name}</p>
|
||||
<p className="text-xs text-secondary-500">
|
||||
{(file.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Upload className="w-10 h-10 text-secondary-400" />
|
||||
<p className="text-sm font-medium text-secondary-700">
|
||||
{t('importExport.dropFileHere', 'CSV-Datei hierher ziehen oder klicken zum Auswählen')}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500">
|
||||
{t('importExport.fileConstraints', 'Max. 10 MB, nur .csv')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fileError && (
|
||||
<div className="flex items-center gap-2 text-sm text-danger-600">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{fileError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handlePreview}
|
||||
disabled={!file || isLoadingPreview}
|
||||
isLoading={isLoadingPreview}
|
||||
icon={!isLoadingPreview ? <Eye className="w-4 h-4" /> : undefined}
|
||||
>
|
||||
{t('importExport.preview', 'Vorschau')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step: Preview */}
|
||||
{step === 'preview' && previewResult && (
|
||||
<Card
|
||||
title={t('importExport.previewTitle', 'Vorschau der Daten')}
|
||||
description={t('importExport.previewDescription', 'Überprüfen Sie das Ergebnis des Dry-Run-Imports')}
|
||||
actions={
|
||||
<Button variant="ghost" size="sm" onClick={handleBack} icon={<ArrowLeft className="w-4 h-4" />}>
|
||||
{t('common.back', 'Zurück')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<StatBox
|
||||
label={t('importExport.totalRows', 'Gesamtzeilen')}
|
||||
value={previewResult.total ?? 0}
|
||||
icon={<FileText className="w-5 h-5 text-secondary-400" />}
|
||||
/>
|
||||
<StatBox
|
||||
label={t('importExport.created', 'Neu')}
|
||||
value={previewResult.created ?? 0}
|
||||
icon={<CheckCircle className="w-5 h-5 text-success-500" />}
|
||||
/>
|
||||
<StatBox
|
||||
label={t('importExport.updated', 'Aktualisiert')}
|
||||
value={previewResult.updated ?? 0}
|
||||
icon={<RotateCcw className="w-5 h-5 text-accent-500" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{previewResult.errors && previewResult.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-danger-500" />
|
||||
<h4 className="text-sm font-semibold text-danger-700">
|
||||
{t('importExport.errors', 'Fehler')} ({previewResult.errors.length})
|
||||
</h4>
|
||||
</div>
|
||||
<ul className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{previewResult.errors.map((err, idx) => (
|
||||
<li key={idx} className="text-sm text-danger-600 bg-danger-50 rounded px-3 py-1.5">
|
||||
{err}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{previewResult.warnings && previewResult.warnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-warning-500" />
|
||||
<h4 className="text-sm font-semibold text-warning-700">
|
||||
{t('importExport.warnings', 'Warnungen')} ({previewResult.warnings.length})
|
||||
</h4>
|
||||
</div>
|
||||
<ul className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{previewResult.warnings.map((warn, idx) => (
|
||||
<li key={idx} className="text-sm text-warning-700 bg-warning-50 rounded px-3 py-1.5">
|
||||
{warn}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview rows */}
|
||||
{previewResult.rows && previewResult.rows.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-secondary-700">
|
||||
{t('importExport.previewRows', 'Vorschau der ersten Zeilen')}
|
||||
</h4>
|
||||
<div className="overflow-x-auto border border-secondary-200 rounded-lg">
|
||||
<table className="min-w-full divide-y divide-secondary-200">
|
||||
<thead className="bg-secondary-50">
|
||||
<tr>
|
||||
{Object.keys(previewResult.rows[0]).slice(0, 6).map((key) => (
|
||||
<th key={key} className="px-3 py-2 text-left text-xs font-medium text-secondary-500 uppercase tracking-wider">
|
||||
{key}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-secondary-200">
|
||||
{previewResult.rows.slice(0, 5).map((row, idx) => (
|
||||
<tr key={idx}>
|
||||
{Object.values(row).slice(0, 6).map((val, vidx) => (
|
||||
<td key={vidx} className="px-3 py-2 text-sm text-secondary-900 whitespace-nowrap">
|
||||
{String(val ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setStep('review')} icon={<CheckCircle className="w-4 h-4" />}>
|
||||
{t('importExport.continue', 'Weiter')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step: Review */}
|
||||
{step === 'review' && (
|
||||
<Card
|
||||
title={t('importExport.reviewTitle', 'Überprüfung')}
|
||||
description={t('importExport.reviewDescription', 'Bestätigen Sie, dass Sie die Vorschau geprüft haben')}
|
||||
actions={
|
||||
<Button variant="ghost" size="sm" onClick={handleBack} icon={<ArrowLeft className="w-4 h-4" />}>
|
||||
{t('common.back', 'Zurück')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="bg-secondary-50 rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600">{t('importExport.entityType', 'Entitätstyp')}</span>
|
||||
<Badge variant="primary">
|
||||
{entityType === 'contacts' ? 'Kontakte' : 'Firmen'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600">{t('importExport.file', 'Datei')}</span>
|
||||
<span className="text-sm font-medium text-secondary-900">{file?.name}</span>
|
||||
</div>
|
||||
{previewResult && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600">{t('importExport.totalRows', 'Gesamtzeilen')}</span>
|
||||
<span className="text-sm font-medium text-secondary-900">{previewResult.total ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600">{t('importExport.created', 'Neu')}</span>
|
||||
<span className="text-sm font-medium text-success-600">{previewResult.created ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600">{t('importExport.updated', 'Aktualisiert')}</span>
|
||||
<span className="text-sm font-medium text-accent-600">{previewResult.updated ?? 0}</span>
|
||||
</div>
|
||||
{previewResult.errors && previewResult.errors.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600">{t('importExport.errors', 'Fehler')}</span>
|
||||
<Badge variant="danger">{previewResult.errors.length}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{previewResult.warnings && previewResult.warnings.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-secondary-600">{t('importExport.warnings', 'Warnungen')}</span>
|
||||
<Badge variant="warning">{previewResult.warnings.length}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmation checkbox */}
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={previewConfirmed}
|
||||
onChange={(e) => setPreviewConfirmed(e.target.checked)}
|
||||
className="mt-1 w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-secondary-700">
|
||||
{t('importExport.confirmPreview', 'Ich habe die Vorschau geprüft und bin mir der Folgen bewusst')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="ghost" onClick={handleReset} icon={<RotateCcw className="w-4 h-4" />}>
|
||||
{t('importExport.restart', 'Neu starten')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!previewConfirmed || isLoadingImport}
|
||||
isLoading={isLoadingImport}
|
||||
icon={!isLoadingImport ? <Play className="w-4 h-4" /> : undefined}
|
||||
>
|
||||
{t('importExport.executeImport', 'Import ausführen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step: Result */}
|
||||
{step === 'result' && importResult && (
|
||||
<Card
|
||||
title={t('importExport.resultTitle', 'Import-Ergebnis')}
|
||||
actions={
|
||||
<Button variant="ghost" size="sm" onClick={handleReset} icon={<RotateCcw className="w-4 h-4" />}>
|
||||
{t('importExport.newImport', 'Neuer Import')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Success / Error indicator */}
|
||||
{importResult.errors && importResult.errors.length > 0 && !importResult.created && !importResult.updated ? (
|
||||
<div className="flex items-center gap-3 bg-danger-50 rounded-lg p-4">
|
||||
<AlertCircle className="w-6 h-6 text-danger-600" />
|
||||
<span className="text-sm font-medium text-danger-700">
|
||||
{t('importExport.importFailed', 'Import fehlgeschlagen')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 bg-success-50 rounded-lg p-4">
|
||||
<CheckCircle className="w-6 h-6 text-success-600" />
|
||||
<span className="text-sm font-medium text-success-700">
|
||||
{t('importExport.importSuccess', 'Import erfolgreich abgeschlossen')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{(importResult.created !== undefined || importResult.updated !== undefined || importResult.total !== undefined) && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
{importResult.total !== undefined && (
|
||||
<StatBox
|
||||
label={t('importExport.totalRows', 'Gesamtzeilen')}
|
||||
value={importResult.total}
|
||||
icon={<FileText className="w-5 h-5 text-secondary-400" />}
|
||||
/>
|
||||
)}
|
||||
{importResult.created !== undefined && (
|
||||
<StatBox
|
||||
label={t('importExport.created', 'Neu')}
|
||||
value={importResult.created}
|
||||
icon={<CheckCircle className="w-5 h-5 text-success-500" />}
|
||||
/>
|
||||
)}
|
||||
{importResult.updated !== undefined && (
|
||||
<StatBox
|
||||
label={t('importExport.updated', 'Aktualisiert')}
|
||||
value={importResult.updated}
|
||||
icon={<RotateCcw className="w-5 h-5 text-accent-500" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{importResult.errors && importResult.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-danger-500" />
|
||||
<h4 className="text-sm font-semibold text-danger-700">
|
||||
{t('importExport.errors', 'Fehler')} ({importResult.errors.length})
|
||||
</h4>
|
||||
</div>
|
||||
<ul className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{importResult.errors.map((err, idx) => (
|
||||
<li key={idx} className="text-sm text-danger-600 bg-danger-50 rounded px-3 py-1.5">
|
||||
{err}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{importResult.warnings && importResult.warnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-warning-500" />
|
||||
<h4 className="text-sm font-semibold text-warning-700">
|
||||
{t('importExport.warnings', 'Warnungen')} ({importResult.warnings.length})
|
||||
</h4>
|
||||
</div>
|
||||
<ul className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{importResult.warnings.map((warn, idx) => (
|
||||
<li key={idx} className="text-sm text-warning-700 bg-warning-50 rounded px-3 py-1.5">
|
||||
{warn}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helper sub-component ───────────────────────────────────────────────────
|
||||
|
||||
function StatBox({ label, value, icon }: { label: string; value: number; icon: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-secondary-50 rounded-lg p-4 flex items-center gap-3">
|
||||
{icon}
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500">{label}</p>
|
||||
<p className="text-lg font-semibold text-secondary-900">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import type { WorkflowStep } from '@/api/workflows';
|
||||
|
||||
const stepTypeOptions = [
|
||||
{ value: 'action', label: 'Action' },
|
||||
{ value: 'approval', label: 'Approval' },
|
||||
{ value: 'notification', label: 'Notification' },
|
||||
{ value: 'condition', label: 'Condition' },
|
||||
];
|
||||
|
||||
const configHints: Record<string, string> = {
|
||||
action: 'Config keys: action_type, target, params',
|
||||
approval: 'Config keys: approver_role, timeout_hours',
|
||||
notification: 'Config keys: channel, template, recipients',
|
||||
condition: 'Config keys: field, operator, value',
|
||||
};
|
||||
|
||||
export interface StepConfigPanelProps {
|
||||
step: WorkflowStep;
|
||||
onChange: (step: WorkflowStep) => void;
|
||||
}
|
||||
|
||||
export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||
const [configText, setConfigText] = useState('');
|
||||
const [configError, setConfigError] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setConfigText(JSON.stringify(step.config ?? {}, null, 2));
|
||||
setConfigError(undefined);
|
||||
}, [step.config]);
|
||||
|
||||
const handleConfigChange = (value: string) => {
|
||||
setConfigText(value);
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
setConfigError(undefined);
|
||||
onChange({ ...step, config: parsed });
|
||||
} catch {
|
||||
setConfigError('Ungültiges JSON');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-secondary-200 p-4 bg-secondary-50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Schritt-Name"
|
||||
required
|
||||
value={step.name}
|
||||
onChange={(e) => onChange({ ...step, name: e.target.value })}
|
||||
placeholder="z.B. Genehmigung einholen"
|
||||
/>
|
||||
<Select
|
||||
label="Typ"
|
||||
options={stepTypeOptions}
|
||||
value={step.type}
|
||||
onChange={(e) =>
|
||||
onChange({ ...step, type: e.target.value as WorkflowStep['type'] })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
value={step.description ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({ ...step, description: e.target.value || null })
|
||||
}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Optionale Beschreibung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
Konfiguration (JSON)
|
||||
</label>
|
||||
{step.type && configHints[step.type] && (
|
||||
<p className="text-xs text-secondary-400 mb-1">
|
||||
{configHints[step.type]}
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
value={configText}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
{configError && (
|
||||
<p className="mt-1 text-sm text-danger-600" role="alert">
|
||||
{configError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { StepConfigPanel } from './StepConfigPanel';
|
||||
import {
|
||||
useCreateWorkflow,
|
||||
useUpdateWorkflow,
|
||||
} from '@/api/workflows';
|
||||
import type { Workflow, WorkflowStep, WorkflowCreateInput, WorkflowUpdateInput } from '@/api/workflows';
|
||||
import { ArrowUp, ArrowDown, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
const triggerEventOptions = [
|
||||
{ value: '', label: '— Kein Trigger —' },
|
||||
{ value: 'contact.created', label: 'contact.created' },
|
||||
{ value: 'contact.updated', label: 'contact.updated' },
|
||||
{ value: 'deal.created', label: 'deal.created' },
|
||||
{ value: 'deal.stage_changed', label: 'deal.stage_changed' },
|
||||
{ value: 'deal.won', label: 'deal.won' },
|
||||
{ value: 'deal.lost', label: 'deal.lost' },
|
||||
{ value: 'task.completed', label: 'task.completed' },
|
||||
{ value: 'task.overdue', label: 'task.overdue' },
|
||||
{ value: 'manual', label: 'manual' },
|
||||
];
|
||||
|
||||
interface EditorFormState {
|
||||
name: string;
|
||||
description: string;
|
||||
trigger_event: string;
|
||||
is_active: boolean;
|
||||
steps: WorkflowStep[];
|
||||
}
|
||||
|
||||
const emptyStep = (): WorkflowStep => ({
|
||||
name: '',
|
||||
type: 'action',
|
||||
config: {},
|
||||
description: null,
|
||||
});
|
||||
|
||||
const emptyForm: EditorFormState = {
|
||||
name: '',
|
||||
description: '',
|
||||
trigger_event: '',
|
||||
is_active: true,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
export interface WorkflowEditorProps {
|
||||
open: boolean;
|
||||
workflow?: Workflow | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const createMutation = useCreateWorkflow();
|
||||
const updateMutation = useUpdateWorkflow();
|
||||
|
||||
const isEdit = !!workflow;
|
||||
const [form, setForm] = useState<EditorFormState>(emptyForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (workflow) {
|
||||
setForm({
|
||||
name: workflow.name,
|
||||
description: workflow.description ?? '',
|
||||
trigger_event: workflow.trigger_event ?? '',
|
||||
is_active: workflow.is_active,
|
||||
steps: workflow.steps?.length
|
||||
? workflow.steps.map((s) => ({
|
||||
name: s.name,
|
||||
type: s.type,
|
||||
config: s.config ?? {},
|
||||
description: s.description ?? null,
|
||||
}))
|
||||
: [],
|
||||
});
|
||||
} else {
|
||||
setForm({ ...emptyForm });
|
||||
}
|
||||
}
|
||||
}, [open, workflow]);
|
||||
|
||||
const updateField = <K extends keyof EditorFormState>(
|
||||
key: K,
|
||||
value: EditorFormState[K]
|
||||
) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const addStep = () => {
|
||||
setForm((prev) => ({ ...prev, steps: [...prev.steps, emptyStep()] }));
|
||||
};
|
||||
|
||||
const updateStep = (index: number, updated: WorkflowStep) => {
|
||||
setForm((prev) => {
|
||||
const steps = [...prev.steps];
|
||||
steps[index] = updated;
|
||||
return { ...prev, steps };
|
||||
});
|
||||
};
|
||||
|
||||
const removeStep = (index: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const moveStep = (index: number, dir: 'up' | 'down') => {
|
||||
setForm((prev) => {
|
||||
const steps = [...prev.steps];
|
||||
const target = dir === 'up' ? index - 1 : index + 1;
|
||||
if (target < 0 || target >= steps.length) return prev;
|
||||
[steps[index], steps[target]] = [steps[target], steps[index]];
|
||||
return { ...prev, steps };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.name.trim()) {
|
||||
toast.error('Name ist erforderlich');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasEmptyStepName = form.steps.some((s) => !s.name.trim());
|
||||
if (hasEmptyStepName) {
|
||||
toast.error('Alle Schritte muessen einen Namen haben');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit && workflow) {
|
||||
const data: WorkflowUpdateInput = {
|
||||
name: form.name,
|
||||
description: form.description || null,
|
||||
trigger_event: form.trigger_event || null,
|
||||
steps: form.steps,
|
||||
is_active: form.is_active,
|
||||
};
|
||||
await updateMutation.mutateAsync({ id: workflow.id, data });
|
||||
toast.success('Workflow aktualisiert');
|
||||
} else {
|
||||
const data: WorkflowCreateInput = {
|
||||
name: form.name,
|
||||
description: form.description || null,
|
||||
trigger_event: form.trigger_event || null,
|
||||
steps: form.steps,
|
||||
is_active: form.is_active,
|
||||
};
|
||||
await createMutation.mutateAsync(data);
|
||||
toast.success('Workflow erstellt');
|
||||
}
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Fehler beim Speichern');
|
||||
}
|
||||
};
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isEdit ? 'Workflow bearbeiten' : 'Neuer Workflow'}
|
||||
size="xl"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
placeholder="z.B. Deal-Genehmigungsprozess"
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Optionale Beschreibung"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label="Trigger-Event"
|
||||
options={triggerEventOptions}
|
||||
value={form.trigger_event}
|
||||
onChange={(e) => updateField('trigger_event', e.target.value)}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_active}
|
||||
onChange={(e) => updateField('is_active', e.target.checked)}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Steps section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-sm font-medium text-secondary-700">
|
||||
Schritte
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={addStep}
|
||||
type="button"
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
>
|
||||
Schritt hinzufuegen
|
||||
</Button>
|
||||
</div>
|
||||
{form.steps.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 italic">
|
||||
Keine Schritte definiert. Klicke auf Schritt hinzufuegen.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{form.steps.map((step, i) => (
|
||||
<div key={i} className="relative">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-secondary-500">
|
||||
Schritt {i + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveStep(i, 'up')}
|
||||
disabled={i === 0}
|
||||
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
|
||||
aria-label="Nach oben"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveStep(i, 'down')}
|
||||
disabled={i === form.steps.length - 1}
|
||||
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
|
||||
aria-label="Nach unten"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStep(i)}
|
||||
className="text-danger-500 hover:text-danger-700 p-1"
|
||||
aria-label="Schritt entfernen"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<StepConfigPanel
|
||||
step={step}
|
||||
onChange={(updated) => updateStep(i, updated)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
|
||||
<Button variant="secondary" onClick={onClose} type="button">
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isSaving}>
|
||||
{isEdit ? 'Speichern' : 'Erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
useWorkflowInstance,
|
||||
useAdvanceWorkflowInstance,
|
||||
useCancelWorkflowInstance,
|
||||
} from '@/api/workflows';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Ban,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
function statusBadgeVariant(
|
||||
status: string
|
||||
): 'success' | 'warning' | 'danger' | 'info' | 'secondary' {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'in_progress':
|
||||
return 'info';
|
||||
case 'pending':
|
||||
return 'warning';
|
||||
case 'rejected':
|
||||
return 'danger';
|
||||
case 'cancelled':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'Wartend',
|
||||
in_progress: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
rejected: 'Abgelehnt',
|
||||
cancelled: 'Abgebrochen',
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
|
||||
function stepTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
action: 'Action',
|
||||
approval: 'Approval',
|
||||
notification: 'Notification',
|
||||
condition: 'Condition',
|
||||
};
|
||||
return map[type] ?? type;
|
||||
}
|
||||
|
||||
export interface WorkflowInstanceDetailProps {
|
||||
instanceId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WorkflowInstanceDetail({
|
||||
instanceId,
|
||||
onClose,
|
||||
}: WorkflowInstanceDetailProps) {
|
||||
const toast = useToast();
|
||||
const { data: instance, isLoading, isError, refetch } =
|
||||
useWorkflowInstance(instanceId);
|
||||
const advanceMutation = useAdvanceWorkflowInstance();
|
||||
const cancelMutation = useCancelWorkflowInstance();
|
||||
|
||||
const [comment, setComment] = useState('');
|
||||
const [showCommentField, setShowCommentField] = useState<
|
||||
'approve' | 'reject' | null
|
||||
>(null);
|
||||
|
||||
const handleAdvance = async (decision: 'approve' | 'reject') => {
|
||||
if (!instance) return;
|
||||
try {
|
||||
await advanceMutation.mutateAsync({
|
||||
instanceId: instance.id,
|
||||
data: {
|
||||
decision,
|
||||
comment: comment || null,
|
||||
},
|
||||
});
|
||||
toast.success(
|
||||
decision === 'approve'
|
||||
? 'Schritt genehmigt'
|
||||
: 'Schritt abgelehnt'
|
||||
);
|
||||
setComment('');
|
||||
setShowCommentField(null);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Fehler bei der Aktion');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!instance) return;
|
||||
try {
|
||||
await cancelMutation.mutateAsync(instance.id);
|
||||
toast.success('Instanz abgebrochen');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Fehler beim Abbrechen');
|
||||
}
|
||||
};
|
||||
|
||||
const canAct =
|
||||
instance?.status === 'in_progress' || instance?.status === 'pending';
|
||||
const canCancel =
|
||||
instance?.status === 'in_progress' || instance?.status === 'pending';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={!!instanceId}
|
||||
onClose={onClose}
|
||||
title="Workflow-Instanz Details"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="flex items-center gap-3 text-danger-600 p-4">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>Fehler beim Laden der Instanz</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-sm text-primary-600 hover:underline"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && instance && (
|
||||
<div className="space-y-6">
|
||||
{/* Header info */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Badge variant={statusBadgeVariant(instance.status)}>
|
||||
{statusLabel(instance.status)}
|
||||
</Badge>
|
||||
{instance.workflow_name && (
|
||||
<span className="text-lg font-semibold text-secondary-900">
|
||||
{instance.workflow_name}
|
||||
</span>
|
||||
)}
|
||||
{!instance.workflow_name && (
|
||||
<span className="text-lg font-semibold text-secondary-900">
|
||||
Workflow {instance.workflow_id.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-secondary-400">
|
||||
ID: {instance.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Key info grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-secondary-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-xs text-secondary-400 mb-1">Aktueller Schritt</p>
|
||||
<p className="text-sm font-medium text-secondary-900">
|
||||
{instance.current_step_index + 1}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-secondary-400 mb-1">Initiiert von</p>
|
||||
<p className="text-sm font-medium text-secondary-900">
|
||||
{instance.initiated_by || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-secondary-400 mb-1">Erstellt am</p>
|
||||
<p className="text-sm font-medium text-secondary-900">
|
||||
{instance.created_at
|
||||
? new Date(instance.created_at).toLocaleString()
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-secondary-400 mb-1">Timeout</p>
|
||||
<p className="text-sm font-medium text-secondary-900 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{instance.timeout_at
|
||||
? new Date(instance.timeout_at).toLocaleString()
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context JSON */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-secondary-700 mb-2">
|
||||
Kontext
|
||||
</h4>
|
||||
<pre className="text-xs font-mono bg-secondary-900 text-secondary-100 rounded-lg p-3 overflow-x-auto max-h-40">
|
||||
{JSON.stringify(instance.context ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Step history timeline */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-secondary-700 mb-3">
|
||||
Schritt-Historie
|
||||
</h4>
|
||||
{instance.history && instance.history.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{instance.history.map((entry, idx) => (
|
||||
<div
|
||||
key={entry.id || idx}
|
||||
className="flex items-start gap-3 pb-3 border-b border-secondary-100 last:border-0"
|
||||
>
|
||||
<div className="flex flex-col items-center flex-shrink-0">
|
||||
<div className="w-6 h-6 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center text-xs font-medium">
|
||||
{entry.step_index + 1}
|
||||
</div>
|
||||
{idx < instance.history.length - 1 && (
|
||||
<div className="w-px h-full bg-secondary-200 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="secondary">
|
||||
{stepTypeLabel(entry.step_type)}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-secondary-900">
|
||||
{entry.action}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-secondary-400">
|
||||
{entry.actor_id && (
|
||||
<span>Aktor: {entry.actor_id}</span>
|
||||
)}
|
||||
{entry.created_at && (
|
||||
<span>
|
||||
{new Date(entry.created_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.details && (
|
||||
<pre className="text-xs font-mono bg-secondary-50 rounded p-2 mt-2 overflow-x-auto max-h-24">
|
||||
{JSON.stringify(entry.details, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-secondary-400 italic">
|
||||
Keine Historie vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{canAct && (
|
||||
<div className="space-y-3 pt-4 border-t border-secondary-200">
|
||||
{showCommentField && (
|
||||
<Input
|
||||
label="Kommentar (optional)"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Kommentar hinzufuegen..."
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{!showCommentField && (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowCommentField('approve')}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
>
|
||||
Genehmigen
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => setShowCommentField('reject')}
|
||||
icon={<XCircle className="h-4 w-4" />}
|
||||
>
|
||||
Ablehnen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{showCommentField === 'approve' && (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleAdvance('approve')}
|
||||
isLoading={advanceMutation.isPending}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
>
|
||||
Bestaetigen
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowCommentField(null);
|
||||
setComment('');
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{showCommentField === 'reject' && (
|
||||
<>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => handleAdvance('reject')}
|
||||
isLoading={advanceMutation.isPending}
|
||||
icon={<XCircle className="h-4 w-4" />}
|
||||
>
|
||||
Ablehnen bestaetigen
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowCommentField(null);
|
||||
setComment('');
|
||||
}}
|
||||
>
|
||||
Zurueck
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canCancel && !showCommentField && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
isLoading={cancelMutation.isPending}
|
||||
icon={<Ban className="h-4 w-4" />}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{instance.status === 'completed' && (
|
||||
<div className="flex items-center gap-2 text-success-700 p-3 bg-success-50 rounded-lg">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">
|
||||
Dieser Workflow ist abgeschlossen
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{instance.status === 'rejected' && (
|
||||
<div className="flex items-center gap-2 text-danger-700 p-3 bg-danger-50 rounded-lg">
|
||||
<XCircle className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">
|
||||
Dieser Workflow wurde abgelehnt
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{instance.status === 'cancelled' && (
|
||||
<div className="flex items-center gap-2 text-secondary-700 p-3 bg-secondary-100 rounded-lg">
|
||||
<Ban className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">
|
||||
Dieser Workflow wurde abgebrochen
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useWorkflowInstances } from '@/api/workflows';
|
||||
import type { InstanceStatus, WorkflowInstance } from '@/api/workflows';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { AlertCircle, ChevronRight } from 'lucide-react';
|
||||
|
||||
const statusFilterOptions = [
|
||||
{ value: '', label: 'Alle Status' },
|
||||
{ value: 'pending', label: 'Wartend' },
|
||||
{ value: 'in_progress', label: 'In Bearbeitung' },
|
||||
{ value: 'completed', label: 'Abgeschlossen' },
|
||||
{ value: 'rejected', label: 'Abgelehnt' },
|
||||
{ value: 'cancelled', label: 'Abgebrochen' },
|
||||
];
|
||||
|
||||
function statusBadgeVariant(
|
||||
status: string
|
||||
): 'success' | 'warning' | 'danger' | 'info' | 'secondary' {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'in_progress':
|
||||
return 'info';
|
||||
case 'pending':
|
||||
return 'warning';
|
||||
case 'rejected':
|
||||
return 'danger';
|
||||
case 'cancelled':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'Wartend',
|
||||
in_progress: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
rejected: 'Abgelehnt',
|
||||
cancelled: 'Abgebrochen',
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
|
||||
export interface WorkflowInstanceListProps {
|
||||
onSelectInstance: (instance: WorkflowInstance) => void;
|
||||
}
|
||||
|
||||
export function WorkflowInstanceList({
|
||||
onSelectInstance,
|
||||
}: WorkflowInstanceListProps) {
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
const queryStatus =
|
||||
statusFilter !== '' ? (statusFilter as InstanceStatus) : undefined;
|
||||
|
||||
const { data, isLoading, isError, refetch } = useWorkflowInstances(
|
||||
page,
|
||||
pageSize,
|
||||
queryStatus
|
||||
);
|
||||
|
||||
const instances = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter bar */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
options={statusFilterOptions}
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<span className="text-sm text-secondary-500">
|
||||
{total} Instanz{total !== 1 ? 'en' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{isError && (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 text-danger-600">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span>Fehler beim Laden der Instanzen</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-sm text-primary-600 hover:underline"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && !isError && instances.length === 0 && (
|
||||
<EmptyState
|
||||
title="Keine Workflow-Instanzen"
|
||||
description="Es wurden keine Instanzen gefunden, die dem Filter entsprechen."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Instance list */}
|
||||
{!isLoading && !isError && instances.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{instances.map((instance) => (
|
||||
<button
|
||||
key={instance.id}
|
||||
onClick={() => onSelectInstance(instance)}
|
||||
className="w-full text-left bg-white rounded-lg border border-secondary-200 hover:border-primary-300 hover:shadow-sm transition-all px-4 py-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Badge variant={statusBadgeVariant(instance.status)}>
|
||||
{statusLabel(instance.status)}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-secondary-900 truncate">
|
||||
{instance.workflow_id}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-400">
|
||||
Schritt {instance.current_step_index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-secondary-400 flex-shrink-0">
|
||||
{instance.initiated_by && (
|
||||
<span>von {instance.initiated_by}</span>
|
||||
)}
|
||||
{instance.created_at && (
|
||||
<span>
|
||||
{new Date(instance.created_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{instance.timeout_at && (
|
||||
<span className="text-warning-600">
|
||||
Timeout: {new Date(instance.timeout_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-secondary-300" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!isLoading && !isError && totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1.5 text-sm rounded-md border border-secondary-300 hover:bg-secondary-50 disabled:opacity-50"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
<span className="text-sm text-secondary-600">
|
||||
Seite {page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1.5 text-sm rounded-md border border-secondary-300 hover:bg-secondary-50 disabled:opacity-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user