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