Files
leocrm/frontend/src/components/workflows/WorkflowInstanceDetail.tsx
T

384 lines
13 KiB
TypeScript

import { asError } from '@/utils/errorTypes';
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: unknown) { const errObj = asError(err);
toast.error(errObj.message || 'Fehler bei der Aktion');
}
};
const handleCancel = async () => {
if (!instance) return;
try {
await cancelMutation.mutateAsync(instance.id);
toast.success('Instanz abgebrochen');
} catch (err: unknown) { const errObj = asError(err);
toast.error(errObj.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>
);
}