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:
@@ -27,6 +27,7 @@ import { SharingSettings } from '@/components/calendar/SharingSettings';
|
||||
import { useCalendarStore, type CalendarViewMode } from '@/store/calendarStore';
|
||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||
import { ChevronLeft, ChevronRight, ExternalLink, Info, Plus } from 'lucide-react';
|
||||
import { PrintButton } from '@/components/common/PrintButton';
|
||||
import {
|
||||
fetchCalendars,
|
||||
createCalendar,
|
||||
@@ -596,8 +597,11 @@ export function CalendarPage() {
|
||||
data-testid="calendar-view-pane"
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-end px-3 py-1.5 border-b border-secondary-200" data-no-print>
|
||||
<PrintButton targetId="calendar-view" />
|
||||
</div>
|
||||
{renderRangeControls()}
|
||||
<div className="flex-1 overflow-hidden">{renderCalendarView()}</div>
|
||||
<div className="flex-1 overflow-hidden" id="calendar-view">{renderCalendarView()}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useWindowStore } from '@/store/windowStore';
|
||||
import { useUnifiedContact, type UnifiedContact } from '@/api/hooks';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { PrintButton } from '@/components/common/PrintButton';
|
||||
|
||||
export function ContactDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -49,8 +50,10 @@ export function ContactDetailPage() {
|
||||
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
<span>{t('contacts.title')}</span>
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<PrintButton targetId="contact-detail" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto" id="contact-detail">
|
||||
<ContactDetail
|
||||
contact={contact ?? null}
|
||||
loading={isLoading}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
useUnifiedContact,
|
||||
type UnifiedContact,
|
||||
} from '@/api/hooks';
|
||||
import { PrintButton } from '@/components/common/PrintButton';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -353,6 +354,7 @@ export function ContactsListPage() {
|
||||
>
|
||||
<Plus className="w-4 h-4 text-primary-600" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
<PrintButton targetId="contacts-table" />
|
||||
</div>
|
||||
|
||||
{/* Saved Filters */}
|
||||
@@ -371,7 +373,7 @@ export function ContactsListPage() {
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0" id="contacts-table">
|
||||
<ContactList
|
||||
contacts={filteredContacts}
|
||||
selectedContactId={selectedContactId}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* DedupMerge page — Main deduplication & merge UI.
|
||||
* Search for duplicate contacts, review pairs, merge with field-level control.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, CopyCheck, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { DuplicatePairCard } from '@/components/dedup/DuplicatePairCard';
|
||||
import { MergeDialog } from '@/components/dedup/MergeDialog';
|
||||
import { MergeHistory } from '@/components/dedup/MergeHistory';
|
||||
import { useFindDuplicates, type DuplicatePair } from '@/api/dedup';
|
||||
|
||||
export function DedupMergePage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [threshold, setThreshold] = useState(0.7);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [mergePair, setMergePair] = useState<DuplicatePair | null>(null);
|
||||
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
|
||||
|
||||
const findDuplicates = useFindDuplicates();
|
||||
const duplicates = findDuplicates.data ?? [];
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setHasSearched(true);
|
||||
findDuplicates.mutate({ threshold, limit });
|
||||
}, [findDuplicates, threshold, limit]);
|
||||
|
||||
const handleMergeClick = useCallback((pair: DuplicatePair) => {
|
||||
setMergePair(pair);
|
||||
setMergeDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleMergeDialogClose = useCallback(() => {
|
||||
setMergeDialogOpen(false);
|
||||
setMergePair(null);
|
||||
}, []);
|
||||
|
||||
const handleLimitChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (isNaN(val) || val < 1) {
|
||||
setLimit(1);
|
||||
} else if (val > 500) {
|
||||
setLimit(500);
|
||||
} else {
|
||||
setLimit(val);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-5xl space-y-6 p-6">
|
||||
{/* Header section */}
|
||||
<div className="rounded-lg border border-secondary-200 bg-white shadow-sm">
|
||||
<div className="border-b border-secondary-200 px-6 py-4">
|
||||
<h1 className="text-xl font-bold text-secondary-900">
|
||||
{t('dedup.title', 'Duplikate & Zusammenführen')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-500">
|
||||
{t(
|
||||
'dedup.description',
|
||||
'Suchen Sie nach dubletten Kontakten und führen Sie diese zusammen.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
{/* Threshold slider */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label htmlFor="threshold-slider" className="text-sm font-medium text-secondary-700">
|
||||
{t('dedup.threshold', 'Ähnlichkeitsschwelle')}
|
||||
</label>
|
||||
<span className="text-sm font-semibold text-primary-700">
|
||||
{Math.round(threshold * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="threshold-slider"
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={1.0}
|
||||
step={0.05}
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(parseFloat(e.target.value))}
|
||||
className="w-full h-2 rounded-lg appearance-none cursor-pointer bg-secondary-200 accent-primary-600"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-secondary-400">
|
||||
<span>50%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Limit input + search button */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="w-full sm:w-40">
|
||||
<Input
|
||||
type="number"
|
||||
label={t('dedup.limit', 'Max. Ergebnisse')}
|
||||
value={limit}
|
||||
onChange={handleLimitChange}
|
||||
min={1}
|
||||
max={500}
|
||||
helperText={t('dedup.limitHelper', '1–500')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
isLoading={findDuplicates.isPending}
|
||||
icon={<Search className="h-4 w-4" />}
|
||||
onClick={handleSearch}
|
||||
>
|
||||
{t('dedup.search', 'Duplikate suchen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results section */}
|
||||
<div className="space-y-4">
|
||||
{/* Loading state */}
|
||||
{findDuplicates.isPending && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary-500" aria-hidden="true" />
|
||||
<p className="mt-3 text-sm text-secondary-500">
|
||||
{t('dedup.searching', 'Suche nach Duplikaten…')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{findDuplicates.isError && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-danger-500" aria-hidden="true" />
|
||||
<p className="mt-3 text-sm font-medium text-danger-700">
|
||||
{t('dedup.searchError', 'Fehler bei der Duplikatssuche.')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-secondary-400">
|
||||
{findDuplicates.error instanceof Error
|
||||
? findDuplicates.error.message
|
||||
: t('common.unknownError', 'Unbekannter Fehler')}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
{t('common.retry', 'Erneut versuchen')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state: no search yet */}
|
||||
{!findDuplicates.isPending && !findDuplicates.isError && !hasSearched && (
|
||||
<EmptyState
|
||||
icon={<CopyCheck className="h-12 w-12" />}
|
||||
title={t('dedup.emptyTitle', 'Keine Suche durchgeführt')}
|
||||
description={t(
|
||||
'dedup.emptyDescription',
|
||||
'Stellen Sie die Schwellwerte ein und klicken Sie auf „Duplikate suchen“, um mögliche Dubletten zu finden.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty results: search done, no duplicates found */}
|
||||
{!findDuplicates.isPending && !findDuplicates.isError && hasSearched && duplicates.length === 0 && (
|
||||
<EmptyState
|
||||
icon={<CopyCheck className="h-12 w-12" />}
|
||||
title={t('dedup.noDuplicates', 'Keine Duplikate gefunden')}
|
||||
description={t(
|
||||
'dedup.noDuplicatesDescription',
|
||||
'Mit den aktuellen Einstellungen wurden keine Duplikate gefunden. Versuchen Sie eine niedrigere Schwellwert.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Results list */}
|
||||
{!findDuplicates.isPending && !findDuplicates.isError && duplicates.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-secondary-900">
|
||||
{t('dedup.results', 'Gefundene Duplikate')}
|
||||
</h2>
|
||||
<span className="rounded-full bg-secondary-100 px-3 py-0.5 text-sm font-medium text-secondary-600">
|
||||
{duplicates.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{duplicates.map((pair, idx) => (
|
||||
<DuplicatePairCard
|
||||
key={`${pair.source_contact.id}-${pair.target_contact.id}-${idx}`}
|
||||
pair={pair}
|
||||
onMerge={handleMergeClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Merge History section */}
|
||||
<MergeHistory />
|
||||
</div>
|
||||
|
||||
{/* Merge Dialog */}
|
||||
<MergeDialog
|
||||
open={mergeDialogOpen}
|
||||
pair={mergePair}
|
||||
onClose={handleMergeDialogClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Upload, Download } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { ImportWizard } from '@/components/import-export/ImportWizard';
|
||||
import { ExportPanel } from '@/components/import-export/ExportPanel';
|
||||
|
||||
type Tab = 'import' | 'export';
|
||||
|
||||
export function ImportExportPage() {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('import');
|
||||
|
||||
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
key: 'import',
|
||||
label: t('importExport.tabImport', 'Import'),
|
||||
icon: <Upload className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
label: t('importExport.tabExport', 'Export'),
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">
|
||||
{t('importExport.title', 'Import / Export')}
|
||||
</h1>
|
||||
<p className="text-sm text-secondary-500 mt-1">
|
||||
{t('importExport.subtitle', 'Daten importieren oder exportieren')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div className="border-b border-secondary-200">
|
||||
<nav className="flex gap-1" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
|
||||
activeTab === tab.key
|
||||
? 'border-primary-600 text-primary-700'
|
||||
: 'border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300'
|
||||
)}
|
||||
aria-current={activeTab === tab.key ? 'page' : undefined}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div>
|
||||
{activeTab === 'import' && <ImportWizard />}
|
||||
{activeTab === 'export' && <ExportPanel />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { PrintButton } from '@/components/common/PrintButton';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
useReportTemplates,
|
||||
@@ -213,6 +214,7 @@ export function ReportsPage() {
|
||||
<BarChart3 className="w-6 h-6 text-primary-600" />
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('reports.title', 'Reports')}</h1>
|
||||
</div>
|
||||
<PrintButton targetId="reports-content" filename="leocrm-report" />
|
||||
</div>
|
||||
|
||||
{/* Preset Quick Actions */}
|
||||
@@ -260,7 +262,7 @@ export function ReportsPage() {
|
||||
</div>
|
||||
|
||||
{/* Main 3-column layout */}
|
||||
<div className="flex flex-1 gap-4 min-h-0">
|
||||
<div className="flex flex-1 gap-4 min-h-0" id="reports-content">
|
||||
{/* Left: Template List */}
|
||||
<div className="w-64 flex-shrink-0 bg-white rounded-lg shadow-sm border border-secondary-200 flex flex-col" data-testid="template-list-panel">
|
||||
<div className="flex items-center justify-between p-3 border-b border-secondary-200">
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useWorkflows,
|
||||
useDeleteWorkflow,
|
||||
useUpdateWorkflow,
|
||||
} from '@/api/workflows';
|
||||
import type { Workflow, WorkflowInstance } from '@/api/workflows';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { WorkflowEditor } from '@/components/workflows/WorkflowEditor';
|
||||
import { WorkflowInstanceList } from '@/components/workflows/WorkflowInstanceList';
|
||||
import { WorkflowInstanceDetail } from '@/components/workflows/WorkflowInstanceDetail';
|
||||
import {
|
||||
Plus,
|
||||
Settings2,
|
||||
Trash2,
|
||||
Zap,
|
||||
AlertCircle,
|
||||
Workflow as WorkflowIcon,
|
||||
ListOrdered,
|
||||
} from 'lucide-react';
|
||||
|
||||
type Tab = 'definitions' | 'instances';
|
||||
|
||||
function activeBadgeVariant(isActive: boolean): 'success' | 'secondary' {
|
||||
return isActive ? 'success' : 'secondary';
|
||||
}
|
||||
|
||||
export function WorkflowsPage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { data, isLoading, isError, refetch } = useWorkflows(1, 50);
|
||||
const deleteMutation = useDeleteWorkflow();
|
||||
const updateMutation = useUpdateWorkflow();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('definitions');
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<Workflow | null>(null);
|
||||
const [selectedInstanceId, setSelectedInstanceId] = useState<string | null>(null);
|
||||
|
||||
const workflows = data?.items ?? [];
|
||||
|
||||
const handleToggleActive = async (workflow: Workflow) => {
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
id: workflow.id,
|
||||
data: { is_active: !workflow.is_active },
|
||||
});
|
||||
toast.success(
|
||||
workflow.is_active ? 'Workflow deaktiviert' : 'Workflow aktiviert'
|
||||
);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Fehler beim Umschalten');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirmDelete) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(confirmDelete.id);
|
||||
toast.success('Workflow geloescht');
|
||||
setConfirmDelete(null);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Fehler beim Loeschen');
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingWorkflow(null);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const openEdit = (workflow: Workflow) => {
|
||||
setEditingWorkflow(workflow);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6" data-testid="workflows-page">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">
|
||||
{t('workflows.title', 'Workflows')}
|
||||
</h1>
|
||||
<p className="text-sm text-secondary-500 mt-1">
|
||||
Definieren und verwalten Sie automatisierte Workflows
|
||||
</p>
|
||||
</div>
|
||||
{activeTab === 'definitions' && (
|
||||
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
|
||||
Neu
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 mb-6 border-b border-secondary-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('definitions')}
|
||||
className={
|
||||
activeTab === 'definitions'
|
||||
? 'px-4 py-2 text-sm font-medium text-primary-600 border-b-2 border-primary-600 -mb-px'
|
||||
: 'px-4 py-2 text-sm font-medium text-secondary-500 hover:text-secondary-700'
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<WorkflowIcon className="h-4 w-4" />
|
||||
Definitionen
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('instances')}
|
||||
className={
|
||||
activeTab === 'instances'
|
||||
? 'px-4 py-2 text-sm font-medium text-primary-600 border-b-2 border-primary-600 -mb-px'
|
||||
: 'px-4 py-2 text-sm font-medium text-secondary-500 hover:text-secondary-700'
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
Instanzen
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Definitions Tab */}
|
||||
{activeTab === 'definitions' && (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 Workflows</span>
|
||||
<Button size="sm" variant="secondary" onClick={() => refetch()}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && workflows.length === 0 && (
|
||||
<EmptyState
|
||||
title="Keine Workflows"
|
||||
description="Erstellen Sie Ihren ersten Workflow, um automatisierte Prozesse zu definieren."
|
||||
icon={<WorkflowIcon className="h-8 w-8" />}
|
||||
action={
|
||||
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
|
||||
Workflow erstellen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && workflows.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{workflows.map((wf) => (
|
||||
<Card key={wf.id} className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="text-lg font-semibold text-secondary-900">
|
||||
{wf.name}
|
||||
</h3>
|
||||
<Badge variant={activeBadgeVariant(wf.is_active)}>
|
||||
{wf.is_active ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
{wf.trigger_event && (
|
||||
<div className="flex items-center gap-1 text-xs text-secondary-400">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>{wf.trigger_event}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{wf.description && (
|
||||
<p className="text-sm text-secondary-500 mb-2">
|
||||
{wf.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-secondary-400">
|
||||
<span>{wf.steps?.length || 0} Schritte</span>
|
||||
{wf.created_at && (
|
||||
<span>
|
||||
Erstellt: {new Date(wf.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wf.is_active}
|
||||
onChange={() => handleToggleActive(wf)}
|
||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="sr-only">Aktiv</span>
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEdit(wf)}
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmDelete(wf)}
|
||||
title="Loeschen"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-danger-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Instances Tab */}
|
||||
{activeTab === 'instances' && (
|
||||
<WorkflowInstanceList
|
||||
onSelectInstance={(inst: WorkflowInstance) =>
|
||||
setSelectedInstanceId(inst.id)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Workflow Editor Modal */}
|
||||
<WorkflowEditor
|
||||
open={showEditor}
|
||||
workflow={editingWorkflow}
|
||||
onClose={() => {
|
||||
setShowEditor(false);
|
||||
setEditingWorkflow(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Instance Detail Modal */}
|
||||
{selectedInstanceId && (
|
||||
<WorkflowInstanceDetail
|
||||
instanceId={selectedInstanceId}
|
||||
onClose={() => setSelectedInstanceId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDelete}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Workflow loeschen"
|
||||
message={`Moechten Sie den Workflow "${confirmDelete?.name}" wirklich loeschen?`}
|
||||
confirmLabel="Loeschen"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user