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

308 lines
9.8 KiB
TypeScript
Raw Normal View History

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;
}
if (form.steps.length === 0) {
toast.error('Mindestens ein Schritt 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) {
// Show detailed validation errors from backend (422)
if (err.validationErrors) {
const details = Object.entries(err.validationErrors)
.map(([field, msgs]) => `${field}: ${msgs.join(', ')}`)
.join('; ');
toast.error(`Validierungsfehler: ${details}`);
} else if (err.detail) {
toast.error(err.detail);
} else {
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>
);
}