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
+5 -1
View File
@@ -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>
+4 -1
View File
@@ -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}
+3 -1
View File
@@ -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}
+225
View File
@@ -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', '1500')}
/>
</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>
);
}
+68
View File
@@ -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>
);
}
+3 -1
View File
@@ -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">
+278
View File
@@ -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>
);
}