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