).en ?? '';
- };
- const renderConfig = () => {
- switch (node.type) {
- case 'input.form': {
- const fields = (params.fields as FormField[]) ?? [];
- const moveField = (fromIndex: number, toIndex: number) => {
- if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
- const next = [...fields];
- const [removed] = next.splice(fromIndex, 1);
- next.splice(toIndex, 0, removed);
- updateParam('fields', next);
- };
- return (
-
-
-
- {fields.map((f, i) => (
-
{
- e.preventDefault();
- e.dataTransfer.dropEffect = 'move';
- }}
- onDrop={(e) => {
- e.preventDefault();
- const from = parseInt(e.dataTransfer.getData('text/plain'), 10);
- if (!Number.isNaN(from) && from !== i) moveField(from, i);
- }}
- >
-
-
-
-
-
-
- ))}
-
-
-
- );
- }
- case 'input.approval':
- return (
- <>
-
-
- updateParam('title', e.target.value)}
- placeholder="Genehmigungstitel"
- />
-
-
-
-
- >
- );
- case 'input.upload':
- return (
- <>
-
-
- updateParam('accept', e.target.value)}
- placeholder=".pdf,image/*"
- />
-
-
-
- updateParam('maxSize', parseFloat(e.target.value) || 0)}
- />
-
-
-
-
- >
- );
- case 'input.comment':
- return (
- <>
-
-
- updateParam('placeholder', e.target.value)}
- placeholder="Kommentar eingeben..."
- />
-
-
-
-
- >
- );
- case 'input.review':
- return (
- <>
-
-
- updateParam('contentRef', e.target.value)}
- placeholder="{{nodeId.field}}"
- />
-
- >
- );
- case 'input.selection': {
- const options = (params.options as Array<{ value?: string; label?: string }>) ?? [];
- return (
-
- );
- }
- case 'input.confirmation':
- return (
- <>
-
-
- updateParam('question', e.target.value)}
- placeholder="Möchten Sie bestätigen?"
- />
-
-
-
- updateParam('confirmLabel', e.target.value)}
- />
-
-
-
- updateParam('rejectLabel', e.target.value)}
- />
-
- >
- );
- default:
- return Keine Konfiguration für {node.type}
;
- }
- };
+ const ConfigRenderer = NODE_CONFIG_REGISTRY[node.type];
+ if (!ConfigRenderer) {
+ return (
+
+
{getLabel(nodeType?.label, language) || node.type}
+
Keine Konfiguration für {node.type}
+
+ );
+ }
return (
-
{getLabel(nt?.label) || node.type}
- {renderConfig()}
+ {getLabel(nodeType?.label, language) || node.type}
+
);
};
diff --git a/src/components/Automation2FlowEditor/NodeListItem.tsx b/src/components/Automation2FlowEditor/NodeListItem.tsx
new file mode 100644
index 0000000..9a2b996
--- /dev/null
+++ b/src/components/Automation2FlowEditor/NodeListItem.tsx
@@ -0,0 +1,49 @@
+/**
+ * NodeListItem - Draggable node type item for the sidebar.
+ * Used in both regular categories and I/O sub-groups.
+ */
+
+import React from 'react';
+import type { NodeType } from '../../api/automation2Api';
+import { getCategoryIcon } from './utils';
+import type { GetLabelFn } from './utils';
+import styles from './Automation2FlowEditor.module.css';
+
+interface NodeListItemProps {
+ node: NodeType;
+ language: string;
+ getLabel: GetLabelFn;
+ getCategoryIcon?: (categoryId: string) => React.ReactNode;
+}
+
+export const NodeListItem: React.FC = ({
+ node,
+ language,
+ getLabel,
+ getCategoryIcon: getIcon = getCategoryIcon,
+}) => (
+ {
+ e.dataTransfer.setData('application/json', JSON.stringify({ type: node.id }));
+ e.dataTransfer.effectAllowed = 'copy';
+ }}
+ >
+
+ {getIcon(node.category)}
+
+
+ {getLabel(node.label, language)}
+ {getLabel(node.description, language)}
+
+
+);
diff --git a/src/components/Automation2FlowEditor/NodeSidebar.tsx b/src/components/Automation2FlowEditor/NodeSidebar.tsx
new file mode 100644
index 0000000..c10d8bd
--- /dev/null
+++ b/src/components/Automation2FlowEditor/NodeSidebar.tsx
@@ -0,0 +1,181 @@
+/**
+ * NodeSidebar - Sidebar with searchable, collapsible node list.
+ * Groups node types by category; I/O nodes are sub-grouped by method.
+ */
+
+import React, { useMemo } from 'react';
+import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
+import type { NodeType, NodeTypeCategory } from '../../api/automation2Api';
+import { IO_METHOD_ORDER, CATEGORY_ORDER } from './constants';
+import { getLabel, getIoMethodLabel } from './utils';
+import { NodeListItem } from './NodeListItem';
+import styles from './Automation2FlowEditor.module.css';
+
+interface NodeSidebarProps {
+ nodeTypes: NodeType[];
+ categories: NodeTypeCategory[];
+ filter: string;
+ onFilterChange: (value: string) => void;
+ language: string;
+ expandedCategories: Set;
+ expandedIoMethods: Set;
+ onToggleCategory: (id: string) => void;
+ onToggleIoMethod: (method: string) => void;
+}
+
+export const NodeSidebar: React.FC = ({
+ nodeTypes,
+ categories,
+ filter,
+ onFilterChange,
+ language,
+ expandedCategories,
+ expandedIoMethods,
+ onToggleCategory,
+ onToggleIoMethod,
+}) => {
+ const filteredNodeTypes = useMemo(() => {
+ if (!filter.trim()) return nodeTypes;
+ const q = filter.toLowerCase();
+ return nodeTypes.filter(
+ (n) =>
+ n.id.toLowerCase().includes(q) ||
+ getLabel(n.label, language).toLowerCase().includes(q) ||
+ getLabel(n.description, language).toLowerCase().includes(q)
+ );
+ }, [nodeTypes, filter, language]);
+
+ const groupedByCategory = useMemo(() => {
+ const map: Record = {};
+ filteredNodeTypes.forEach((n) => {
+ const cat = n.category || 'other';
+ if (!map[cat]) map[cat] = [];
+ map[cat].push(n);
+ });
+ return map;
+ }, [filteredNodeTypes]);
+
+ const ioSubGroups = useMemo(() => {
+ const ioNodes = groupedByCategory['io'] || [];
+ const byMethod: Record = {};
+ ioNodes.forEach((n) => {
+ const method = n.meta?.method ?? n.id.split('.')[1] ?? 'other';
+ if (!byMethod[method]) byMethod[method] = [];
+ byMethod[method].push(n);
+ });
+ const ordered: Array<{ method: string; nodes: NodeType[] }> = [];
+ const methodOrder = [...IO_METHOD_ORDER];
+ methodOrder.forEach((m) => {
+ if (byMethod[m]?.length) ordered.push({ method: m, nodes: byMethod[m] });
+ });
+ Object.keys(byMethod).forEach((m) => {
+ if (!methodOrder.includes(m)) ordered.push({ method: m, nodes: byMethod[m] });
+ });
+ return ordered;
+ }, [groupedByCategory]);
+
+ const orderedCategories = useMemo(() => {
+ const seen = new Set();
+ const result: string[] = [];
+ CATEGORY_ORDER.forEach((id) => {
+ if (groupedByCategory[id]) {
+ result.push(id);
+ seen.add(id);
+ }
+ });
+ Object.keys(groupedByCategory).forEach((id) => {
+ if (!seen.has(id)) result.push(id);
+ });
+ return result;
+ }, [groupedByCategory]);
+
+ const getLabelFn = (t: string | Record | undefined, lang?: string) =>
+ getLabel(t, lang ?? language);
+
+ return (
+
+
+
Nodes
+ onFilterChange(e.target.value)}
+ />
+
+
+ {orderedCategories.map((catId) => {
+ const isExpanded = expandedCategories.has(catId);
+ const catLabel = categories.find((c) => c.id === catId);
+ const label = getLabel(catLabel?.label, language) || catId;
+
+ if (catId === 'io' && ioSubGroups.length > 0) {
+ return (
+
+ {ioSubGroups.map(({ method, nodes }) => {
+ const methodLabel = getIoMethodLabel(method, language);
+ const isMethodExpanded = expandedIoMethods.has(method);
+ return (
+
+
+ {isMethodExpanded &&
+ nodes.map((node) => (
+
+ ))}
+
+ );
+ })}
+
+ );
+ }
+
+ const items = groupedByCategory[catId] || [];
+ return (
+
+
+ {isExpanded &&
+ items.map((node) => (
+
+ ))}
+
+ );
+ })}
+
+
+ );
+};
diff --git a/src/components/Automation2FlowEditor/categoryIcons.tsx b/src/components/Automation2FlowEditor/categoryIcons.tsx
new file mode 100644
index 0000000..035e3eb
--- /dev/null
+++ b/src/components/Automation2FlowEditor/categoryIcons.tsx
@@ -0,0 +1,17 @@
+/**
+ * Category icons for node types
+ */
+
+import React from 'react';
+import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser } from 'react-icons/fa';
+
+export const CATEGORY_ICONS: Record = {
+ trigger: ,
+ input: ,
+ flow: ,
+ data: ,
+ io: ,
+ human: ,
+};
+
+export const DEFAULT_CATEGORY_ICON = ;
diff --git a/src/components/Automation2FlowEditor/configs/ApprovalNodeConfig.tsx b/src/components/Automation2FlowEditor/configs/ApprovalNodeConfig.tsx
new file mode 100644
index 0000000..fbb6af7
--- /dev/null
+++ b/src/components/Automation2FlowEditor/configs/ApprovalNodeConfig.tsx
@@ -0,0 +1,27 @@
+/**
+ * Approval node config
+ */
+
+import React from 'react';
+import type { NodeConfigRendererProps } from './types';
+
+export const ApprovalNodeConfig: React.FC = ({ params, updateParam }) => (
+ <>
+
+
+ updateParam('title', e.target.value)}
+ placeholder="Genehmigungstitel"
+ />
+
+
+
+
+ >
+);
diff --git a/src/components/Automation2FlowEditor/configs/CommentNodeConfig.tsx b/src/components/Automation2FlowEditor/configs/CommentNodeConfig.tsx
new file mode 100644
index 0000000..e199788
--- /dev/null
+++ b/src/components/Automation2FlowEditor/configs/CommentNodeConfig.tsx
@@ -0,0 +1,29 @@
+/**
+ * Comment node config
+ */
+
+import React from 'react';
+import type { NodeConfigRendererProps } from './types';
+
+export const CommentNodeConfig: React.FC = ({ params, updateParam }) => (
+ <>
+
+
+ updateParam('placeholder', e.target.value)}
+ placeholder="Kommentar eingeben..."
+ />
+
+
+
+
+ >
+);
diff --git a/src/components/Automation2FlowEditor/configs/ConfirmationNodeConfig.tsx b/src/components/Automation2FlowEditor/configs/ConfirmationNodeConfig.tsx
new file mode 100644
index 0000000..a030903
--- /dev/null
+++ b/src/components/Automation2FlowEditor/configs/ConfirmationNodeConfig.tsx
@@ -0,0 +1,33 @@
+/**
+ * Confirmation node config
+ */
+
+import React from 'react';
+import type { NodeConfigRendererProps } from './types';
+
+export const ConfirmationNodeConfig: React.FC = ({ params, updateParam }) => (
+ <>
+
+
+ updateParam('question', e.target.value)}
+ placeholder="Möchten Sie bestätigen?"
+ />
+
+
+
+ updateParam('confirmLabel', e.target.value)}
+ />
+
+
+
+ updateParam('rejectLabel', e.target.value)}
+ />
+
+ >
+);
diff --git a/src/components/Automation2FlowEditor/configs/FormNodeConfig.tsx b/src/components/Automation2FlowEditor/configs/FormNodeConfig.tsx
new file mode 100644
index 0000000..27588e9
--- /dev/null
+++ b/src/components/Automation2FlowEditor/configs/FormNodeConfig.tsx
@@ -0,0 +1,126 @@
+/**
+ * Form node config - draggable fields, types, required toggle
+ */
+
+import React from 'react';
+import { FaGripVertical, FaTimes } from 'react-icons/fa';
+import type { FormField, NodeConfigRendererProps } from './types';
+import styles from '../Automation2FlowEditor.module.css';
+
+export const FormNodeConfig: React.FC = ({ params, updateParam }) => {
+ const fields = (params.fields as FormField[]) ?? [];
+
+ const moveField = (fromIndex: number, toIndex: number) => {
+ if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
+ const next = [...fields];
+ const [removed] = next.splice(fromIndex, 1);
+ next.splice(toIndex, 0, removed);
+ updateParam('fields', next);
+ };
+
+ const removeField = (index: number) => {
+ const next = fields.filter((_, i) => i !== index);
+ updateParam('fields', next);
+ };
+
+ return (
+
+
+
+ {fields.map((f, i) => (
+
{
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ }}
+ onDrop={(e) => {
+ e.preventDefault();
+ const from = parseInt(e.dataTransfer.getData('text/plain'), 10);
+ if (!Number.isNaN(from) && from !== i) moveField(from, i);
+ }}
+ >
+
+
+
+
+
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/components/Automation2FlowEditor/configs/ReviewNodeConfig.tsx b/src/components/Automation2FlowEditor/configs/ReviewNodeConfig.tsx
new file mode 100644
index 0000000..a413a7d
--- /dev/null
+++ b/src/components/Automation2FlowEditor/configs/ReviewNodeConfig.tsx
@@ -0,0 +1,17 @@
+/**
+ * Review node config
+ */
+
+import React from 'react';
+import type { NodeConfigRendererProps } from './types';
+
+export const ReviewNodeConfig: React.FC = ({ params, updateParam }) => (
+
+
+ updateParam('contentRef', e.target.value)}
+ placeholder="{{nodeId.field}}"
+ />
+
+);
diff --git a/src/components/Automation2FlowEditor/configs/SelectionNodeConfig.tsx b/src/components/Automation2FlowEditor/configs/SelectionNodeConfig.tsx
new file mode 100644
index 0000000..adbbabb
--- /dev/null
+++ b/src/components/Automation2FlowEditor/configs/SelectionNodeConfig.tsx
@@ -0,0 +1,50 @@
+/**
+ * Selection node config
+ */
+
+import React from 'react';
+import type { NodeConfigRendererProps } from './types';
+
+export const SelectionNodeConfig: React.FC = ({ params, updateParam }) => {
+ const options = (params.options as Array<{ value?: string; label?: string }>) ?? [];
+ return (
+
+ );
+};
diff --git a/src/components/Automation2FlowEditor/configs/UploadNodeConfig.tsx b/src/components/Automation2FlowEditor/configs/UploadNodeConfig.tsx
new file mode 100644
index 0000000..4d9b640
--- /dev/null
+++ b/src/components/Automation2FlowEditor/configs/UploadNodeConfig.tsx
@@ -0,0 +1,37 @@
+/**
+ * Upload node config
+ */
+
+import React from 'react';
+import type { NodeConfigRendererProps } from './types';
+
+export const UploadNodeConfig: React.FC = ({ params, updateParam }) => (
+ <>
+
+
+ updateParam('accept', e.target.value)}
+ placeholder=".pdf,image/*"
+ />
+
+
+
+ updateParam('maxSize', parseFloat(e.target.value) || 0)}
+ />
+
+
+
+
+ >
+);
diff --git a/src/components/Automation2FlowEditor/configs/index.ts b/src/components/Automation2FlowEditor/configs/index.ts
new file mode 100644
index 0000000..e529c71
--- /dev/null
+++ b/src/components/Automation2FlowEditor/configs/index.ts
@@ -0,0 +1,26 @@
+/**
+ * Node config renderers - one per input node type.
+ * Add new node types here.
+ */
+
+import type { ComponentType } from 'react';
+import type { NodeConfigRendererProps } from './types';
+import { FormNodeConfig } from './FormNodeConfig';
+import { ApprovalNodeConfig } from './ApprovalNodeConfig';
+import { UploadNodeConfig } from './UploadNodeConfig';
+import { CommentNodeConfig } from './CommentNodeConfig';
+import { ReviewNodeConfig } from './ReviewNodeConfig';
+import { SelectionNodeConfig } from './SelectionNodeConfig';
+import { ConfirmationNodeConfig } from './ConfirmationNodeConfig';
+
+export type NodeConfigComponent = ComponentType;
+
+export const NODE_CONFIG_REGISTRY: Record = {
+ 'input.form': FormNodeConfig,
+ 'input.approval': ApprovalNodeConfig,
+ 'input.upload': UploadNodeConfig,
+ 'input.comment': CommentNodeConfig,
+ 'input.review': ReviewNodeConfig,
+ 'input.selection': SelectionNodeConfig,
+ 'input.confirmation': ConfirmationNodeConfig,
+};
diff --git a/src/components/Automation2FlowEditor/configs/types.ts b/src/components/Automation2FlowEditor/configs/types.ts
new file mode 100644
index 0000000..b095b54
--- /dev/null
+++ b/src/components/Automation2FlowEditor/configs/types.ts
@@ -0,0 +1,10 @@
+/**
+ * Shared types for node config renderers
+ */
+
+export type FormField = { name?: string; type?: string; label?: string; required?: boolean };
+
+export interface NodeConfigRendererProps {
+ params: Record;
+ updateParam: (key: string, value: unknown) => void;
+}
diff --git a/src/components/Automation2FlowEditor/constants.ts b/src/components/Automation2FlowEditor/constants.ts
new file mode 100644
index 0000000..c2855fc
--- /dev/null
+++ b/src/components/Automation2FlowEditor/constants.ts
@@ -0,0 +1,28 @@
+/**
+ * Automation2 Flow Editor - Constants
+ * I/O method configuration, category ordering.
+ */
+
+/** I/O nodes: order for sub-groups (KI, Kontext, Outlook, etc.) */
+export const IO_METHOD_ORDER = [
+ 'ai',
+ 'context',
+ 'outlook',
+ 'sharepoint',
+ 'jira',
+ 'trustee',
+ 'chatbot',
+] as const;
+
+export const IO_METHOD_LABELS: Record> = {
+ ai: { de: 'KI', en: 'AI', fr: 'IA' },
+ context: { de: 'Kontext', en: 'Context', fr: 'Contexte' },
+ outlook: { de: 'Outlook', en: 'Outlook', fr: 'Outlook' },
+ sharepoint: { de: 'SharePoint', en: 'SharePoint', fr: 'SharePoint' },
+ jira: { de: 'Jira', en: 'Jira', fr: 'Jira' },
+ trustee: { de: 'Trustee', en: 'Trustee', fr: 'Trustee' },
+ chatbot: { de: 'Chatbot', en: 'Chatbot', fr: 'Chatbot' },
+};
+
+/** Default category display order */
+export const CATEGORY_ORDER = ['trigger', 'input', 'flow', 'data', 'io'] as const;
diff --git a/src/components/Automation2FlowEditor/graphUtils.ts b/src/components/Automation2FlowEditor/graphUtils.ts
new file mode 100644
index 0000000..252e4f5
--- /dev/null
+++ b/src/components/Automation2FlowEditor/graphUtils.ts
@@ -0,0 +1,78 @@
+/**
+ * Automation2 Flow Editor - Graph conversion utilities
+ * Converts between API graph format and canvas internal format.
+ */
+
+import type { NodeType } from '../../api/automation2Api';
+import type { CanvasNode, CanvasConnection } from './FlowCanvas';
+import type { Automation2Graph } from '../../api/automation2Api';
+
+export function fromApiGraph(
+ graph: Automation2Graph,
+ nodeTypes: NodeType[]
+): { nodes: CanvasNode[]; connections: CanvasConnection[] } {
+ const nodeMap = new Map();
+ nodeTypes.forEach((nt) => {
+ nodeMap.set(nt.id, { inputs: nt.inputs ?? 1, outputs: nt.outputs ?? 1 });
+ });
+
+ const nodes: CanvasNode[] = (graph.nodes || []).map((n) => {
+ const io = nodeMap.get(n.type) ?? { inputs: 1, outputs: 1 };
+ return {
+ id: n.id,
+ type: n.type,
+ x: (n as { x?: number }).x ?? 0,
+ y: (n as { y?: number }).y ?? 0,
+ title: (n as { title?: string }).title ?? (typeof n.type === 'string' ? n.type : ''),
+ comment: (n as { comment?: string }).comment,
+ inputs: io.inputs,
+ outputs: io.outputs,
+ parameters: n.parameters ?? {},
+ };
+ });
+
+ const connId = (s: string, t: string, so: number, ti: number) => `c_${s}_${so}_${t}_${ti}`;
+ const connections: CanvasConnection[] = (graph.connections || []).map((c) => {
+ const srcNode = nodes.find((n) => n.id === c.source);
+ const sourceOutput = c.sourceOutput ?? 0;
+ const sourceHandle = srcNode ? srcNode.inputs + sourceOutput : 0;
+ return {
+ id: connId(c.source, c.target, sourceOutput, c.targetInput ?? 0),
+ sourceId: c.source,
+ sourceHandle,
+ targetId: c.target,
+ targetHandle: c.targetInput ?? 0,
+ };
+ });
+
+ return { nodes, connections };
+}
+
+export function toApiGraph(
+ nodes: CanvasNode[],
+ connections: CanvasConnection[]
+): Automation2Graph {
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
+ return {
+ nodes: nodes.map((n) => ({
+ id: n.id,
+ type: n.type,
+ x: n.x,
+ y: n.y,
+ title: n.title,
+ comment: n.comment,
+ parameters: n.parameters ?? {},
+ })),
+ connections: connections.map((c) => {
+ const srcNode = nodeMap.get(c.sourceId);
+ const sourceOutput =
+ srcNode && c.sourceHandle >= srcNode.inputs ? c.sourceHandle - srcNode.inputs : 0;
+ return {
+ source: c.sourceId,
+ target: c.targetId,
+ sourceOutput,
+ targetInput: c.targetHandle,
+ };
+ }),
+ };
+}
diff --git a/src/components/Automation2FlowEditor/index.ts b/src/components/Automation2FlowEditor/index.ts
index 961dd07..ae55f50 100644
--- a/src/components/Automation2FlowEditor/index.ts
+++ b/src/components/Automation2FlowEditor/index.ts
@@ -1 +1,9 @@
export { Automation2FlowEditor } from './Automation2FlowEditor';
+export { FlowCanvas } from './FlowCanvas';
+export { NodeConfigPanel } from './NodeConfigPanel';
+export { NodeSidebar } from './NodeSidebar';
+export { NodeListItem } from './NodeListItem';
+export { CanvasHeader } from './CanvasHeader';
+export * from './utils';
+export * from './constants';
+export * from './graphUtils';
diff --git a/src/components/Automation2FlowEditor/utils.ts b/src/components/Automation2FlowEditor/utils.ts
new file mode 100644
index 0000000..78b3e24
--- /dev/null
+++ b/src/components/Automation2FlowEditor/utils.ts
@@ -0,0 +1,31 @@
+/**
+ * Automation2 Flow Editor - Utility functions
+ */
+
+import type React from 'react';
+import { CATEGORY_ICONS, DEFAULT_CATEGORY_ICON } from './categoryIcons';
+import { IO_METHOD_LABELS } from './constants';
+
+/** Resolve localized label from string or { de, en, fr } object */
+export function getLabel(
+ text: string | Record | undefined,
+ lang = 'de'
+): string {
+ if (!text) return '';
+ if (typeof text === 'string') return text;
+ const rec = text as Record;
+ return rec[lang] ?? rec.en ?? '';
+}
+
+/** Get icon for a category */
+export function getCategoryIcon(categoryId: string): React.ReactNode {
+ return CATEGORY_ICONS[categoryId] ?? DEFAULT_CATEGORY_ICON;
+}
+
+/** Get label for I/O method (ai, context, outlook, ...) */
+export function getIoMethodLabel(method: string, lang: string): string {
+ return IO_METHOD_LABELS[method]?.[lang] ?? IO_METHOD_LABELS[method]?.en ?? method;
+}
+
+/** Function type for resolving localized labels */
+export type GetLabelFn = (text: string | Record | undefined, lang?: string) => string;
diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx
index 00074e6..d7c4003 100644
--- a/src/config/pageRegistry.tsx
+++ b/src/config/pageRegistry.tsx
@@ -113,6 +113,7 @@ export const PAGE_ICONS: Record = {
'feature.automation': ,
'feature.automation2': ,
'page.feature.automation2.editor': ,
+ 'page.feature.automation2.workflows': ,
'page.feature.automation2.workflows-tasks': ,
'page.feature.chatbot.conversations': ,
'feature.chatbot': ,
diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx
index db41888..85ccc0d 100644
--- a/src/pages/FeatureView.tsx
+++ b/src/pages/FeatureView.tsx
@@ -33,6 +33,7 @@ import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView
// Automation2 Views
import { Automation2Page } from './views/automation2/Automation2Page';
+import { Automation2WorkflowsPage } from './views/automation2/Automation2WorkflowsPage';
import { Automation2WorkflowsTasksPage } from './views/automation2/Automation2WorkflowsTasksPage';
// Workspace Views
@@ -135,6 +136,7 @@ const VIEW_COMPONENTS: Record> = {
},
automation2: {
editor: Automation2Page,
+ workflows: Automation2WorkflowsPage,
'workflows-tasks': Automation2WorkflowsTasksPage,
},
workspace: {
diff --git a/src/pages/views/automation2/Automation2Page.tsx b/src/pages/views/automation2/Automation2Page.tsx
index 9fe7b6b..36b25d6 100644
--- a/src/pages/views/automation2/Automation2Page.tsx
+++ b/src/pages/views/automation2/Automation2Page.tsx
@@ -4,6 +4,7 @@
* n8n-style flow builder with backend-driven node list.
*/
import React from 'react';
+import { useSearchParams } from 'react-router-dom';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { Automation2FlowEditor } from '../../../components/Automation2FlowEditor';
@@ -11,6 +12,8 @@ import styles from '../../FeatureView.module.css';
export const Automation2Page: React.FC = () => {
const instanceId = useInstanceId();
+ const [searchParams] = useSearchParams();
+ const workflowId = searchParams.get('workflowId');
const { currentLanguage } = useLanguage();
const language = (currentLanguage?.slice(0, 2) || 'de') as string;
@@ -25,7 +28,11 @@ export const Automation2Page: React.FC = () => {
return (
);
};
diff --git a/src/pages/views/automation2/Automation2WorkflowsPage.tsx b/src/pages/views/automation2/Automation2WorkflowsPage.tsx
new file mode 100644
index 0000000..159e2b5
--- /dev/null
+++ b/src/pages/views/automation2/Automation2WorkflowsPage.tsx
@@ -0,0 +1,233 @@
+/**
+ * Automation2WorkflowsPage
+ * List of saved workflows with FormGeneratorTable.
+ * Shows: label, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
+ * Actions: Edit (navigate to editor), Delete, Execute.
+ */
+
+import React, { useState, useCallback, useEffect } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { FaPlay, FaSync } from 'react-icons/fa';
+import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
+import { useInstanceId } from '../../../hooks/useCurrentInstance';
+import { useApiRequest } from '../../../hooks/useApi';
+import {
+ fetchWorkflows,
+ deleteWorkflow,
+ executeGraph,
+ type Automation2Workflow,
+} from '../../../api/automation2Api';
+import { useToast } from '../../../contexts/ToastContext';
+import { formatUnixTimestamp } from '../../../utils/time';
+import styles from '../../../pages/admin/Admin.module.css';
+
+function formatTs(ts?: number): string {
+ if (ts == null || ts <= 0) return '—';
+ const sec = ts < 1e12 ? ts : ts / 1000;
+ const { time } = formatUnixTimestamp(sec, undefined, {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ return time;
+}
+
+export const Automation2WorkflowsPage: React.FC = () => {
+ const instanceId = useInstanceId();
+ const { mandateId } = useParams<{ mandateId: string }>();
+ const { request } = useApiRequest();
+ const navigate = useNavigate();
+ const { showSuccess, showError } = useToast();
+
+ const [workflows, setWorkflows] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [executingId, setExecutingId] = useState(null);
+
+ const load = useCallback(async () => {
+ if (!instanceId) return;
+ setLoading(true);
+ try {
+ const list = await fetchWorkflows(request, instanceId);
+ setWorkflows(list);
+ } catch (e) {
+ console.error('[Automation2] load workflows failed', e);
+ showError('Fehler beim Laden der Workflows');
+ } finally {
+ setLoading(false);
+ }
+ }, [instanceId, request, showError]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const handleDelete = useCallback(
+ async (workflowId: string): Promise => {
+ if (!instanceId) return false;
+ try {
+ await deleteWorkflow(request, instanceId, workflowId);
+ showSuccess('Workflow gelöscht');
+ await load();
+ return true;
+ } catch (e: any) {
+ showError(`Fehler: ${e?.message || 'Löschen fehlgeschlagen'}`);
+ return false;
+ }
+ },
+ [instanceId, request, showSuccess, showError, load]
+ );
+
+ const handleEdit = useCallback(
+ (row: Automation2Workflow) => {
+ if (!mandateId || !instanceId) return;
+ navigate(`/mandates/${mandateId}/automation2/${instanceId}/editor?workflowId=${row.id}`);
+ },
+ [mandateId, instanceId, navigate]
+ );
+
+ const handleExecute = useCallback(
+ async (row: Automation2Workflow) => {
+ if (!instanceId) return;
+ setExecutingId(row.id);
+ try {
+ const result = await executeGraph(request, instanceId, row.graph!, row.id);
+ if (result?.success) {
+ if (result?.paused) {
+ showSuccess('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.');
+ } else {
+ showSuccess('Workflow ausgeführt');
+ }
+ await load();
+ } else {
+ showError(result?.error || 'Ausführung fehlgeschlagen');
+ }
+ } catch (e: any) {
+ showError(`Fehler: ${e?.message || 'Ausführung fehlgeschlagen'}`);
+ } finally {
+ setExecutingId(null);
+ }
+ },
+ [instanceId, request, showSuccess, showError, load]
+ );
+
+ const columns: ColumnConfig[] = [
+ { key: 'label', label: 'Workflow', type: 'string', width: 200, sortable: true },
+ {
+ key: 'isRunning',
+ label: 'Läuft',
+ type: 'boolean',
+ width: 80,
+ formatter: (value: boolean) =>
+ value ? (
+ ✓ Ja
+ ) : (
+ Nein
+ ),
+ },
+ {
+ key: 'stuckAtNodeLabel',
+ label: 'Steht bei',
+ type: 'string',
+ width: 160,
+ formatter: (value: string, row: Automation2Workflow) =>
+ row.isRunning && (value || row.stuckAtNodeId)
+ ? value || row.stuckAtNodeId || '—'
+ : '—',
+ },
+ {
+ key: 'createdAt',
+ label: 'Erstellt',
+ type: 'number',
+ width: 140,
+ formatter: (v: number) => formatTs(v),
+ },
+ {
+ key: 'lastStartedAt',
+ label: 'Zuletzt gestartet',
+ type: 'number',
+ width: 160,
+ formatter: (v: number) => formatTs(v),
+ },
+ {
+ key: 'runCount',
+ label: 'Läufe',
+ type: 'number',
+ width: 80,
+ formatter: (v: number) => (v != null ? String(v) : '0'),
+ },
+ ];
+
+ const hookData = {
+ refetch: load,
+ handleDelete: (id: string) => handleDelete(id),
+ };
+
+ if (!instanceId) {
+ return (
+
+
Keine Feature-Instanz gefunden.
+
+ );
+ }
+
+ return (
+
+
+
+
Gespeicherte Workflows
+
+ Workflows verwalten, ausführen und bearbeiten
+
+
+
+
+
+
+
+
+
+ data={workflows}
+ columns={columns}
+ loading={loading}
+ pagination={true}
+ pageSize={25}
+ searchable={true}
+ filterable={true}
+ sortable={true}
+ selectable={false}
+ actionButtons={[
+ {
+ type: 'edit',
+ title: 'Bearbeiten',
+ onAction: handleEdit,
+ },
+ {
+ type: 'delete',
+ title: 'Löschen',
+ },
+ ]}
+ customActions={[
+ {
+ id: 'execute',
+ icon: ,
+ title: 'Ausführen',
+ onClick: (row) => handleExecute(row),
+ loading: (row) => executingId === row.id,
+ },
+ ]}
+ onDelete={(row) => handleDelete(row.id)}
+ hookData={hookData}
+ emptyMessage="Keine Workflows gefunden. Erstelle einen im Editor."
+ />
+
+
+ );
+};
diff --git a/src/pages/views/automation2/Automation2WorkflowsTasks.module.css b/src/pages/views/automation2/Automation2WorkflowsTasks.module.css
index 7cd698a..6fcb79b 100644
--- a/src/pages/views/automation2/Automation2WorkflowsTasks.module.css
+++ b/src/pages/views/automation2/Automation2WorkflowsTasks.module.css
@@ -1,6 +1,6 @@
.container {
padding: 1.5rem;
- max-width: 800px;
+ max-width: 900px;
}
.container h2 {
@@ -8,6 +8,77 @@
font-size: 1.25rem;
}
+.section {
+ margin-bottom: 1.5rem;
+}
+
+.sectionTitle {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0 0 0.75rem 0;
+ font-size: 1rem;
+ font-weight: 600;
+}
+
+.completedHeader {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.6rem 0;
+ text-align: left;
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary, #333);
+}
+
+.completedHeader:hover {
+ color: var(--primary-color, #007bff);
+}
+
+.completedList {
+ max-height: 360px;
+ overflow-y: auto;
+ padding-top: 0.5rem;
+}
+
+.taskMeta {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 0.5rem 1.25rem;
+ margin-bottom: 0.75rem;
+ padding-bottom: 0.75rem;
+ border-bottom: 1px solid var(--border-color, #e0e0e0);
+}
+
+.taskMetaRow {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.metaLabel {
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--text-secondary, #666);
+}
+
+.metaValue {
+ font-size: 0.9rem;
+ color: var(--text-primary, #333);
+}
+
+.metaValueMono {
+ font-size: 0.75rem;
+ font-family: monospace;
+ color: var(--text-secondary, #666);
+}
+
.loading {
display: flex;
flex-direction: column;
diff --git a/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx b/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
index 8bd2426..9c075cb 100644
--- a/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
+++ b/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
@@ -1,40 +1,67 @@
/**
* Automation2WorkflowsTasksPage
- * Workflows (collapsible) with runs and tasks. Complete tasks by type (form, approval, upload, etc.)
- * Form tasks open in Popup for fill-in and submit.
+ * Tasks only (no workflow grouping).
+ * Open tasks at top, completed tasks at bottom (expandable, scrollable).
+ * Each task shows workflow, created, due, step, type, and action.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { FaChevronDown, FaChevronRight, FaSpinner } from 'react-icons/fa';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import {
- fetchWorkflows,
fetchTasks,
completeTask,
- type Automation2Workflow,
type Automation2Task,
} from '../../../api/automation2Api';
import { Popup } from '../../../components/UiComponents/Popup';
import styles from './Automation2WorkflowsTasks.module.css';
+const NODE_TYPE_LABELS: Record = {
+ 'input.form': 'Formular',
+ 'input.approval': 'Genehmigung',
+ 'input.upload': 'Upload',
+ 'input.comment': 'Kommentar',
+ 'input.review': 'Prüfung',
+ 'input.selection': 'Auswahl',
+ 'input.confirmation': 'Bestätigung',
+};
+
+function formatTimestamp(ts?: number): string {
+ if (ts == null || ts <= 0) return '—';
+ const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts);
+ return d.toLocaleString('de-DE', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+function getNodeStepLabel(config: Record): string {
+ const title = config?.title;
+ if (typeof title === 'string' && title.trim()) return title;
+ const label = config?.label;
+ if (typeof label === 'string' && label.trim()) return label;
+ if (typeof label === 'object' && label != null && 'de' in (label as Record)) {
+ return (label as Record).de ?? (label as Record).en ?? '';
+ }
+ return '';
+}
+
export const Automation2WorkflowsTasksPage: React.FC = () => {
const instanceId = useInstanceId();
const { request } = useApiRequest();
- const [workflows, setWorkflows] = useState([]);
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
- const [expandedWorkflows, setExpandedWorkflows] = useState>(new Set());
+ const [completedExpanded, setCompletedExpanded] = useState(false);
const [submitting, setSubmitting] = useState(null);
const load = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
try {
- const [wfList, taskList] = await Promise.all([
- fetchWorkflows(request, instanceId),
- fetchTasks(request, instanceId, { status: 'pending' }),
- ]);
- setWorkflows(wfList);
+ const taskList = await fetchTasks(request, instanceId);
setTasks(taskList);
} catch (e) {
console.error('[Automation2] load failed', e);
@@ -47,15 +74,6 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
load();
}, [load]);
- const toggleWorkflow = (id: string) => {
- setExpandedWorkflows((prev) => {
- const next = new Set(prev);
- if (next.has(id)) next.delete(id);
- else next.add(id);
- return next;
- });
- };
-
const handleComplete = async (taskId: string, result: Record) => {
if (!instanceId) return;
setSubmitting(taskId);
@@ -69,19 +87,13 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
}
};
- const tasksByWorkflow = tasks.reduce>((acc, t) => {
- const w = t.workflowId;
- if (!acc[w]) acc[w] = [];
- acc[w].push(t);
- return acc;
- }, {});
-
- const workflowLabel = (wf: Automation2Workflow) => wf.label || wf.id;
+ const openTasks = tasks.filter((t) => t.status === 'pending');
+ const completedTasks = tasks.filter((t) => t.status !== 'pending');
if (!instanceId) {
return (
-
Workflows & Tasks
+
Tasks
Keine Feature-Instanz gefunden.
);
@@ -91,52 +103,68 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
return (
-
Lade Workflows und Tasks…
+
Lade Tasks…
);
}
return (
-
Workflows & Tasks
-
- {workflows.map((wf) => {
- const isExpanded = expandedWorkflows.has(wf.id);
- const wfTasks = tasksByWorkflow[wf.id] ?? [];
- return (
-
-
- {isExpanded && (
-
- {wfTasks.length === 0 ? (
-
Keine offenen Tasks
- ) : (
- wfTasks.map((task) => (
-
handleComplete(task.id, result)}
- submitting={submitting === task.id}
- />
- ))
- )}
-
- )}
-
- );
- })}
-
- {workflows.length === 0 && (
-
Keine Workflows. Erstelle einen im Editor und speichere ihn.
- )}
+
Tasks
+
+ {/* Open tasks */}
+
+
+ Offene Tasks
+ {openTasks.length > 0 && {openTasks.length}}
+
+ {openTasks.length === 0 ? (
+ Keine offenen Tasks
+ ) : (
+
+ {openTasks.map((task) => (
+ handleComplete(task.id, result)}
+ submitting={submitting === task.id}
+ />
+ ))}
+
+ )}
+
+
+ {/* Completed tasks */}
+
+
+ {completedExpanded && (
+
+ {completedTasks.length === 0 ? (
+
Keine erledigten Tasks
+ ) : (
+ completedTasks.map((task) => (
+
handleComplete(task.id, result)}
+ submitting={submitting === task.id}
+ readOnly
+ />
+ ))
+ )}
+
+ )}
+
);
};
@@ -145,18 +173,28 @@ interface TaskCardProps {
task: Automation2Task;
onSubmit: (result: Record) => void;
submitting: boolean;
+ readOnly?: boolean;
}
-const TaskCard: React.FC = ({ task, onSubmit, submitting }) => {
+const TaskCard: React.FC = ({
+ task,
+ onSubmit,
+ submitting,
+ readOnly = false,
+}) => {
const [formData, setFormData] = useState>({});
const [formPopupOpen, setFormPopupOpen] = useState(false);
const config = task.config ?? {};
const nodeType = task.nodeType;
+ const stepLabel = getNodeStepLabel(config);
const renderInput = () => {
+ if (readOnly) return null;
switch (nodeType) {
case 'input.form': {
- const fields = (config.fields as Array<{ name: string; type: string; label: string; required?: boolean }>) ?? [];
+ const fields =
+ (config.fields as Array<{ name: string; type: string; label: string; required?: boolean }>) ??
+ [];
const requiredFields = fields.filter((f) => f.required);
const allRequiredFilled = requiredFields.every((f) => {
const v = formData[f.name];
@@ -167,18 +205,27 @@ const TaskCard: React.FC = ({ task, onSubmit, submitting }) => {
{fields.map((f) => (
-
+
{f.type === 'boolean' ? (
setFormData((p) => ({ ...p, [f.name]: e.target.checked }))}
+ onChange={(e) =>
+ setFormData((p) => ({ ...p, [f.name]: e.target.checked }))
+ }
/>
) : (
setFormData((p) => ({ ...p, [f.name]: e.target.value }))}
+ onChange={(e) =>
+ setFormData((p) => ({ ...p, [f.name]: e.target.value }))
+ }
/>
)}
@@ -222,13 +269,21 @@ const TaskCard: React.FC
= ({ task, onSubmit, submitting }) => {
case 'input.approval':
return (
-
{config.title}
- {config.description &&
{config.description}
}
+ {config.title &&
{config.title as string}
}
+ {config.description &&
{config.description as string}
}
-
@@ -245,14 +300,18 @@ const TaskCard: React.FC
= ({ task, onSubmit, submitting }) => {
onSubmit(formData)}
- disabled={submitting || ((config.required !== false) && !formData.comment)}
+ disabled={
+ submitting ||
+ ((config.required !== false) && !formData.comment)
+ }
>
Absenden
);
case 'input.selection': {
- const options = (config.options as Array<{ value: string; label: string }>) ?? [];
+ const options =
+ (config.options as Array<{ value: string; label: string }>) ?? [];
const multiple = config.multiple as boolean;
return (
@@ -265,7 +324,9 @@ const TaskCard: React.FC
= ({ task, onSubmit, submitting }) => {
onChange={(e) => {
if (multiple) {
const prev = (formData.selected as string[]) ?? [];
- const next = e.target.checked ? [...prev, o.value] : prev.filter((v) => v !== o.value);
+ const next = e.target.checked
+ ? [...prev, o.value]
+ : prev.filter((v) => v !== o.value);
setFormData((p) => ({ ...p, selected: next }));
} else {
setFormData((p) => ({ ...p, selected: o.value }));
@@ -311,7 +372,11 @@ const TaskCard: React.FC = ({ task, onSubmit, submitting }) => {
return (
Upload-Komponente – noch nicht implementiert
-
onSubmit({ uploaded: [] })} disabled={submitting}>
+ onSubmit({ uploaded: [] })}
+ disabled={submitting}
+ >
Platzhalter absenden
@@ -325,7 +390,11 @@ const TaskCard: React.FC = ({ task, onSubmit, submitting }) => {
value={(formData.feedback as string) ?? ''}
onChange={(e) => setFormData({ feedback: e.target.value })}
/>
- onSubmit(formData)} disabled={submitting}>
+ onSubmit(formData)}
+ disabled={submitting}
+ >
Absenden
@@ -334,7 +403,11 @@ const TaskCard: React.FC = ({ task, onSubmit, submitting }) => {
return (
Unbekannter Task-Typ: {nodeType}
-
onSubmit({})} disabled={submitting}>
+ onSubmit({})}
+ disabled={submitting}
+ >
Absenden
@@ -342,19 +415,46 @@ const TaskCard: React.FC = ({ task, onSubmit, submitting }) => {
}
};
- const nodeTypeLabel: Record = {
- 'input.form': 'Formular',
- 'input.approval': 'Genehmigung',
- 'input.upload': 'Upload',
- 'input.comment': 'Kommentar',
- 'input.review': 'Prüfung',
- 'input.selection': 'Auswahl',
- 'input.confirmation': 'Bestätigung',
- };
-
return (
-
{nodeTypeLabel[nodeType] ?? nodeType}
+
+
+ Workflow
+
+ {task.workflowLabel || task.workflowId || '—'}
+
+
+
+ Erstellt
+
+ {formatTimestamp(task.createdAt)}
+
+
+
+ Fällig
+
+ {formatTimestamp(task.dueAt)}
+
+
+ {stepLabel && (
+
+ Schritt
+ {stepLabel}
+
+ )}
+
+ Typ
+
+ {NODE_TYPE_LABELS[nodeType] ?? nodeType}
+
+
+ {task.nodeId && (
+
+ Node
+ {task.nodeId}
+
+ )}
+
{renderInput()}
);
diff --git a/src/types/mandate.ts b/src/types/mandate.ts
index d8653d4..dc1460e 100644
--- a/src/types/mandate.ts
+++ b/src/types/mandate.ts
@@ -267,7 +267,8 @@ export const FEATURE_REGISTRY: Record = {
icon: 'sitemap',
views: [
{ code: 'editor', label: { de: 'Editor', en: 'Editor' }, path: 'editor' },
- { code: 'workflows-tasks', label: { de: 'Workflows & Tasks', en: 'Workflows & Tasks' }, path: 'workflows-tasks' },
+ { code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
+ { code: 'workflows-tasks', label: { de: 'Tasks', en: 'Tasks' }, path: 'workflows-tasks' },
]
},
neutralization: {