Phase 1: Workflows UI, Dedup/Merge UI, Import/Export UI, Print/PDF

- Workflows UI: full page with definitions/instances tabs, step editor, instance detail with approve/reject
- Dedup/Merge UI: duplicate detection, side-by-side comparison, field-level merge dialog, merge history
- Import/Export UI: import wizard (dry-run preview), export panel (CSV/XLSX), backend export route added
- Print/PDF: PrintButton component, print.css, integrated in Contacts/Calendar/Reports/ContactDetail
- Backend: GET /api/v1/export endpoint, export_companies_csv() service function
- Routes: /workflows, /contacts/dedup, /import-export registered
- Menu items: Workflows, Import/Export, Duplikate added to automation plugin manifest
- IMPLEMENTATION_PLAN.md: audit-corrected plan for all 14 remaining features
This commit is contained in:
Agent Zero
2026-07-26 02:35:44 +02:00
parent 6d484ed747
commit a3a5a10514
27 changed files with 4288 additions and 5 deletions
@@ -0,0 +1,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>
);
}