560 lines
18 KiB
TypeScript
560 lines
18 KiB
TypeScript
import { asError } from '@/utils/errorTypes';
|
|
import React, { useState, useEffect } from 'react';
|
|
// TODO: P2-F21 — Replace hardcoded triggerEventOptions with backend config
|
|
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, WorkflowStepType, WorkflowCreateInput, WorkflowUpdateInput } from '@/api/workflows';
|
|
import { ArrowUp, ArrowDown, Plus, Trash2, Code2, FormInput, LayoutTemplate } 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: [],
|
|
};
|
|
|
|
interface WorkflowTemplate {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
form: EditorFormState;
|
|
}
|
|
|
|
const templates: WorkflowTemplate[] = [
|
|
{
|
|
id: 'welcome-email',
|
|
name: 'Welcome Email',
|
|
description: 'Sendet eine Willkommens-Mail an neue Kontakte.',
|
|
form: {
|
|
name: 'Welcome Email',
|
|
description: 'Sendet eine Willkommens-Mail an neue Kontakte.',
|
|
trigger_event: 'contact.created',
|
|
is_active: true,
|
|
steps: [
|
|
{
|
|
name: 'Willkommens-Mail senden',
|
|
type: 'mail',
|
|
config: {
|
|
to: '{{contact.email}}',
|
|
subject: 'Willkommen bei uns!',
|
|
body: 'Hallo {{contact.first_name}}, willkommen bei unserem Unternehmen!',
|
|
},
|
|
description: 'Sendet die Willkommens-Mail an den neuen Kontakt.',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
id: 'contact-follow-up',
|
|
name: 'Contact Follow-Up',
|
|
description: 'Wartet 3 Tage und sendet dann ein Follow-Up.',
|
|
form: {
|
|
name: 'Contact Follow-Up',
|
|
description: 'Wartet 3 Tage und sendet dann ein Follow-Up.',
|
|
trigger_event: 'contact.created',
|
|
is_active: true,
|
|
steps: [
|
|
{
|
|
name: '3 Tage warten',
|
|
type: 'wait',
|
|
config: { duration_seconds: 259200 },
|
|
description: 'Wartet 3 Tage nach Kontakterstellung.',
|
|
},
|
|
{
|
|
name: 'Follow-Up senden',
|
|
type: 'mail',
|
|
config: {
|
|
to: '{{contact.email}}',
|
|
subject: 'Wie können wir helfen?',
|
|
body: 'Hallo {{contact.first_name}}, wir wollten nachfragen, ob wir helfen können.',
|
|
},
|
|
description: 'Sendet das Follow-Up an den Kontakt.',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
id: 'approval-chain',
|
|
name: 'Approval Chain',
|
|
description: 'Deal-Genehmigung mit zweistufiger Freigabe.',
|
|
form: {
|
|
name: 'Approval Chain',
|
|
description: 'Deal-Genehmigung mit zweistufiger Freigabe.',
|
|
trigger_event: 'deal.stage_changed',
|
|
is_active: true,
|
|
steps: [
|
|
{
|
|
name: 'Vertriebsleiter-Genehmigung',
|
|
type: 'approval',
|
|
config: { approver_role: 'sales_manager', timeout_hours: 24 },
|
|
description: 'Erste Freigabe durch den Vertriebsleiter.',
|
|
},
|
|
{
|
|
name: 'Geschäftsführer-Genehmigung',
|
|
type: 'approval',
|
|
config: { approver_role: 'ceo', timeout_hours: 48 },
|
|
description: 'Zweite Freigabe durch die Geschäftsführung.',
|
|
},
|
|
{
|
|
name: 'Bestätigungs-Mail',
|
|
type: 'notification',
|
|
config: { channel: 'email', template: 'deal_approved', recipients: '{{deal.owner_email}}' },
|
|
description: 'Benachrichtigt den Deal-Owner über die Freigabe.',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
type EditorMode = 'form' | 'json';
|
|
|
|
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);
|
|
const [mode, setMode] = useState<EditorMode>('form');
|
|
const [jsonText, setJsonText] = useState('');
|
|
const [jsonError, setJsonError] = useState<string | undefined>(undefined);
|
|
const [showTemplates, setShowTemplates] = useState(false);
|
|
|
|
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 });
|
|
}
|
|
setMode('form');
|
|
setShowTemplates(false);
|
|
setJsonError(undefined);
|
|
}
|
|
}, [open, workflow]);
|
|
|
|
useEffect(() => {
|
|
setJsonText(JSON.stringify(form, null, 2));
|
|
setJsonError(undefined);
|
|
}, [form]);
|
|
|
|
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 applyTemplate = (template: WorkflowTemplate) => {
|
|
setForm({
|
|
name: template.form.name,
|
|
description: template.form.description,
|
|
trigger_event: template.form.trigger_event,
|
|
is_active: template.form.is_active,
|
|
steps: template.form.steps.map((s) => ({ ...s, config: { ...s.config } })),
|
|
});
|
|
setShowTemplates(false);
|
|
};
|
|
|
|
const handleJsonChange = (value: string) => {
|
|
setJsonText(value);
|
|
try {
|
|
const parsed = JSON.parse(value) as EditorFormState;
|
|
if (!Array.isArray(parsed.steps)) {
|
|
setJsonError('steps muss ein Array sein');
|
|
return;
|
|
}
|
|
setJsonError(undefined);
|
|
setForm(parsed);
|
|
} catch {
|
|
setJsonError('Ungültiges JSON');
|
|
}
|
|
};
|
|
|
|
const requiredConfigFields: Partial<Record<WorkflowStepType, string[]>> = {
|
|
wait: ['duration_seconds'],
|
|
http: ['url'],
|
|
mail: ['to', 'subject'],
|
|
calendar: ['action'],
|
|
dms: ['action'],
|
|
search: ['query'],
|
|
agent: ['agent_id'],
|
|
crm: ['action'],
|
|
};
|
|
|
|
const validateSteps = (steps: WorkflowStep[]): string | null => {
|
|
if (steps.length === 0) {
|
|
return 'Mindestens ein Schritt ist erforderlich';
|
|
}
|
|
for (let i = 0; i < steps.length; i++) {
|
|
const s = steps[i];
|
|
if (!s.name.trim()) {
|
|
return `Schritt ${i + 1}: Name ist erforderlich`;
|
|
}
|
|
const required = requiredConfigFields[s.type];
|
|
if (required) {
|
|
for (const field of required) {
|
|
const v = s.config[field];
|
|
if (v === undefined || v === null || v === '') {
|
|
return `Schritt ${i + 1} (${s.type}): Feld "${field}" ist erforderlich`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!form.name.trim()) {
|
|
toast.error('Name ist erforderlich');
|
|
return;
|
|
}
|
|
|
|
const stepError = validateSteps(form.steps);
|
|
if (stepError) {
|
|
toast.error(stepError);
|
|
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: unknown) { const errObj = asError(err);
|
|
// Show detailed validation errors from backend (422)
|
|
if (errObj.validationErrors) {
|
|
const details = Object.entries(errObj.validationErrors)
|
|
.map(([field, msgs]) => `${field}: ${(msgs as string[]).join(', ')}`)
|
|
.join('; ');
|
|
toast.error(`Validierungsfehler: ${details}`);
|
|
} else if (errObj.detail) {
|
|
toast.error(errObj.detail);
|
|
} else {
|
|
toast.error(errObj.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">
|
|
{/* Mode toggle + template gallery */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{!isEdit && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
type="button"
|
|
onClick={() => setShowTemplates((v) => !v)}
|
|
icon={<LayoutTemplate className="h-4 w-4" />}
|
|
>
|
|
Vorlagen
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 rounded-md border border-secondary-200 bg-white p-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode('form')}
|
|
className={`inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium ${
|
|
mode === 'form'
|
|
? 'bg-primary-600 text-white'
|
|
: 'text-secondary-600 hover:bg-secondary-100'
|
|
}`}
|
|
aria-pressed={mode === 'form'}
|
|
>
|
|
<FormInput className="h-3.5 w-3.5" /> Formular
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode('json')}
|
|
className={`inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium ${
|
|
mode === 'json'
|
|
? 'bg-primary-600 text-white'
|
|
: 'text-secondary-600 hover:bg-secondary-100'
|
|
}`}
|
|
aria-pressed={mode === 'json'}
|
|
>
|
|
<Code2 className="h-3.5 w-3.5" /> JSON Expert
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Template gallery */}
|
|
{showTemplates && !isEdit && (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
{templates.map((template) => (
|
|
<button
|
|
key={template.id}
|
|
type="button"
|
|
onClick={() => applyTemplate(template)}
|
|
className="rounded-lg border border-secondary-200 bg-secondary-50 p-4 text-left hover:border-primary-500 hover:bg-primary-50 transition-colors"
|
|
>
|
|
<span className="block text-sm font-medium text-secondary-800">
|
|
{template.name}
|
|
</span>
|
|
<span className="mt-1 block text-xs text-secondary-500">
|
|
{template.description}
|
|
</span>
|
|
<span className="mt-2 block text-xs text-primary-600 font-medium">
|
|
{template.form.steps.length} Schritte
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{mode === 'form' ? (
|
|
<>
|
|
<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>
|
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
|
Workflow (JSON)
|
|
</label>
|
|
<textarea
|
|
value={jsonText}
|
|
onChange={(e) => handleJsonChange(e.target.value)}
|
|
rows={18}
|
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
placeholder='{"name": "...", "steps": [...]}'
|
|
/>
|
|
{jsonError && (
|
|
<p className="mt-1 text-sm text-danger-600" role="alert">
|
|
{jsonError}
|
|
</p>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|