([]);
+ const [loading, setLoading] = useState(false);
+ const [foldersLoading, setFoldersLoading] = useState(false);
+
+ useEffect(() => {
+ if (instanceId && request) {
+ setLoading(true);
+ fetchConnections(request, instanceId)
+ .then(setConnections)
+ .catch(() => setConnections([]))
+ .finally(() => setLoading(false));
+ }
+ }, [instanceId, request]);
+
+ const connectionId = (params.connectionId as string) ?? '';
+ const selectedConn = connections.find((c) => c.id === connectionId);
+ const mailService = selectedConn?.authority === 'google' ? 'gmail' : 'outlook';
+
+ useEffect(() => {
+ if (instanceId && request && connectionId) {
+ setFoldersLoading(true);
+ fetchBrowse(request, instanceId, connectionId, mailService, '/')
+ .then((r) => setFolders(r.items.filter((e) => e.isFolder)))
+ .catch(() => setFolders([]))
+ .finally(() => setFoldersLoading(false));
+ } else {
+ setFolders([]);
+ }
+ }, [instanceId, request, connectionId, mailService]);
+
+ const isDraft = nodeType === 'email.draftEmail';
+ const isSearch = nodeType === 'email.searchEmail';
+ const folderValue = (params.folder as string) ?? (isSearch ? 'All' : 'Inbox');
+
+ return (
+ <>
+
+
+
+
+ {!isDraft && (
+
+
+
+
+ )}
+ {isSearch && (
+ <>
+
+
+ updateParam('query', e.target.value)}
+ placeholder="General search term (subject, body, from)"
+ />
+
+
+
+ updateParam('fromAddress', e.target.value)}
+ placeholder="e.g. sender@example.com"
+ />
+
+
+
+ updateParam('toAddress', e.target.value)}
+ placeholder="e.g. recipient@example.com"
+ />
+
+
+
+ updateParam('subjectContains', e.target.value)}
+ placeholder="Word or phrase in subject"
+ />
+
+
+
+ updateParam('bodyContains', e.target.value)}
+ placeholder="Word or phrase in email body"
+ />
+
+
+ updateParam('hasAttachment', e.target.checked)}
+ />
+
+
+
+
+ updateParam('limit', parseInt(e.target.value, 10) || 100)}
+ />
+
+ >
+ )}
+ {nodeType === 'email.checkEmail' && (
+ <>
+
+
+ updateParam('fromAddress', e.target.value)}
+ placeholder="e.g. sender@example.com"
+ />
+
+
+
+ updateParam('subjectContains', e.target.value)}
+ placeholder="Word or phrase in subject"
+ />
+
+
+ updateParam('hasAttachment', e.target.checked)}
+ />
+
+
+
+
+ updateParam('limit', parseInt(e.target.value, 10) || 100)}
+ />
+
+ >
+ )}
+ {isDraft && (
+ <>
+
+
+ updateParam('subject', e.target.value)}
+ placeholder="Email subject (or leave empty if connected to AI node above)"
+ />
+
+
+
+
+
+
+ updateParam('to', e.target.value)}
+ placeholder="Recipient(s) (or from AI when connected)"
+ />
+
+ >
+ )}
+ >
+ );
+};
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/SharePointNodeConfig.tsx b/src/components/Automation2FlowEditor/configs/SharePointNodeConfig.tsx
new file mode 100644
index 0000000..9f03daf
--- /dev/null
+++ b/src/components/Automation2FlowEditor/configs/SharePointNodeConfig.tsx
@@ -0,0 +1,245 @@
+/**
+ * SharePoint node config - connection selector, path, search query.
+ * Uses SharepointBrowseTree (FolderTree-style) for file selection.
+ */
+
+import React, { useEffect, useState, useCallback } from 'react';
+import type { NodeConfigRendererProps } from './types';
+import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../api/automation2Api';
+import { SharepointBrowseTree } from '../../FolderTree/SharepointBrowseTree';
+
+export const SharePointNodeConfig: React.FC = ({
+ params,
+ updateParam,
+ instanceId,
+ request,
+ nodeType = 'sharepoint.findFile',
+}) => {
+ const [connections, setConnections] = useState([]);
+ const [browseExpanded, setBrowseExpanded] = useState(false);
+ const [copySourceExpanded, setCopySourceExpanded] = useState(false);
+ const [copyDestExpanded, setCopyDestExpanded] = useState(false);
+ const [connectionsLoading, setConnectionsLoading] = useState(false);
+
+ const connectionId = (params.connectionId as string) ?? '';
+ const pathParam = 'path';
+ const path = (params.path as string) ?? (params.filePath as string) ?? '';
+
+ useEffect(() => {
+ if (instanceId && request) {
+ setConnectionsLoading(true);
+ fetchConnections(request, instanceId)
+ .then(setConnections)
+ .catch(() => setConnections([]))
+ .finally(() => setConnectionsLoading(false));
+ }
+ }, [instanceId, request]);
+
+ const loadChildren = useCallback(
+ async (pathToLoad: string): Promise => {
+ if (!instanceId || !request || !connectionId) return [];
+ const r = await fetchBrowse(request, instanceId, connectionId, 'sharepoint', pathToLoad);
+ return r?.items ?? [];
+ },
+ [instanceId, request, connectionId]
+ );
+
+ const selectPath = useCallback(
+ (p: string) => {
+ updateParam(pathParam, p);
+ setBrowseExpanded(false);
+ },
+ [updateParam, pathParam]
+ );
+
+ const selectSourcePath = useCallback(
+ (p: string) => {
+ updateParam('sourcePath', p);
+ setCopySourceExpanded(false);
+ },
+ [updateParam]
+ );
+
+ const selectDestPath = useCallback(
+ (p: string) => {
+ updateParam('destPath', p);
+ setCopyDestExpanded(false);
+ },
+ [updateParam]
+ );
+
+ const needsPath = !['sharepoint.findFile'].includes(nodeType);
+ const needsSearch = nodeType === 'sharepoint.findFile';
+ const needsSiteId = false;
+ const hasPathInput = ['sharepoint.readFile', 'sharepoint.uploadFile', 'sharepoint.downloadFile', 'sharepoint.copyFile'].includes(nodeType);
+
+ return (
+ <>
+
+
+
+
+ {needsSearch && (
+
+
+ updateParam('searchQuery', e.target.value)}
+ placeholder="/sites/SiteName/Shared Documents or search term"
+ />
+
+ )}
+ {needsPath && nodeType === 'sharepoint.listFiles' && (
+
+
+ updateParam('path', e.target.value)}
+ placeholder="/ or /sites/SiteName/Shared Documents/Folder"
+ />
+
+ )}
+ {needsPath && ['sharepoint.readFile', 'sharepoint.uploadFile', 'sharepoint.downloadFile'].includes(nodeType) && (
+
+
+ updateParam('path', e.target.value)}
+ placeholder={
+ nodeType === 'sharepoint.downloadFile'
+ ? '/sites/SiteName/Shared Documents/file.pdf'
+ : nodeType === 'sharepoint.uploadFile'
+ ? '/sites/.../Shared Documents/TargetFolder/'
+ : 'File or folder path'
+ }
+ />
+
+ )}
+ {needsSiteId && (
+
+
+ updateParam('siteId', e.target.value)}
+ placeholder="SharePoint site ID"
+ />
+
+ )}
+ {nodeType === 'sharepoint.copyFile' && (
+ <>
+
+
+ updateParam('sourcePath', e.target.value)}
+ placeholder="/sites/.../folder/file.pdf"
+ />
+
+
+
+ updateParam('destPath', e.target.value)}
+ placeholder="/sites/.../target-folder/"
+ />
+
+ {connectionId && (
+ <>
+ setCopySourceExpanded((e.target as HTMLDetailsElement).open)}
+ style={{
+ marginTop: 12,
+ border: '1px solid var(--border-color, #e0e0e0)',
+ borderRadius: 6,
+ background: 'var(--bg-secondary, #f8f9fa)',
+ overflow: 'hidden',
+ }}
+ >
+
+ 📂 Source file durchsuchen
+
+
+
+
+
+ setCopyDestExpanded((e.target as HTMLDetailsElement).open)}
+ style={{
+ marginTop: 8,
+ border: '1px solid var(--border-color, #e0e0e0)',
+ borderRadius: 6,
+ background: 'var(--bg-secondary, #f8f9fa)',
+ overflow: 'hidden',
+ }}
+ >
+
+ 📂 Zielordner durchsuchen
+
+
+ {}} onSelectFolder={selectDestPath} selectedPath={(params.destPath as string) || null} />
+
+
+ >
+ )}
+ >
+ )}
+ {connectionId && needsPath && hasPathInput && !['sharepoint.copyFile'].includes(nodeType) && (
+ setBrowseExpanded((e.target as HTMLDetailsElement).open)}
+ style={{
+ marginTop: 12,
+ border: '1px solid var(--border-color, #e0e0e0)',
+ borderRadius: 6,
+ background: 'var(--bg-secondary, #f8f9fa)',
+ overflow: 'hidden',
+ }}
+ >
+
+ 📂
+ SharePoint durchsuchen
+
+
+
+
+
+ )}
+ >
+ );
+};
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..d674c75
--- /dev/null
+++ b/src/components/Automation2FlowEditor/configs/index.ts
@@ -0,0 +1,44 @@
+/**
+ * Node config renderers - one per node type (input, ai, email, sharepoint).
+ */
+
+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';
+import { AiNodeConfig } from './AiNodeConfig';
+import { EmailNodeConfig } from './EmailNodeConfig';
+import { SharePointNodeConfig } from './SharePointNodeConfig';
+
+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,
+ 'ai.prompt': AiNodeConfig,
+ 'ai.webResearch': AiNodeConfig,
+ 'ai.summarizeDocument': AiNodeConfig,
+ 'ai.translateDocument': AiNodeConfig,
+ 'ai.convertDocument': AiNodeConfig,
+ 'ai.generateDocument': AiNodeConfig,
+ 'ai.generateCode': AiNodeConfig,
+ 'email.checkEmail': EmailNodeConfig,
+ 'email.searchEmail': EmailNodeConfig,
+ 'email.draftEmail': EmailNodeConfig,
+ 'sharepoint.findFile': SharePointNodeConfig,
+ 'sharepoint.readFile': SharePointNodeConfig,
+ 'sharepoint.uploadFile': SharePointNodeConfig,
+ 'sharepoint.listFiles': SharePointNodeConfig,
+ 'sharepoint.downloadFile': SharePointNodeConfig,
+ 'sharepoint.copyFile': SharePointNodeConfig,
+};
diff --git a/src/components/Automation2FlowEditor/configs/types.ts b/src/components/Automation2FlowEditor/configs/types.ts
new file mode 100644
index 0000000..f25d758
--- /dev/null
+++ b/src/components/Automation2FlowEditor/configs/types.ts
@@ -0,0 +1,16 @@
+/**
+ * Shared types for node config renderers
+ */
+
+import type { ApiRequestFunction } from '../../../api/automation2Api';
+
+export type FormField = { name?: string; type?: string; label?: string; required?: boolean };
+
+export interface NodeConfigRendererProps {
+ params: Record;
+ updateParam: (key: string, value: unknown) => void;
+ /** For Email/SharePoint: fetch connections and browse */
+ instanceId?: string;
+ request?: ApiRequestFunction;
+ nodeType?: string;
+}
diff --git a/src/components/Automation2FlowEditor/constants.ts b/src/components/Automation2FlowEditor/constants.ts
new file mode 100644
index 0000000..38859c9
--- /dev/null
+++ b/src/components/Automation2FlowEditor/constants.ts
@@ -0,0 +1,38 @@
+/**
+ * Automation2 Flow Editor - Constants
+ * Category ordering for node sidebar.
+ */
+
+/** Node type IDs hidden from the sidebar (hidden, not removed – still work when present in saved graphs) */
+export const HIDDEN_NODE_IDS = new Set([
+ 'trigger.schedule', // zeitplan
+ 'trigger.formSubmit', // formular-absendung
+ 'flow.ifElse',
+ 'flow.switch',
+ 'flow.merge',
+ 'flow.loop',
+ 'flow.wait',
+ 'flow.stop', // alle abschnitt ablauf
+ 'data.setFields',
+ 'data.filter',
+ 'data.parseJson',
+ 'data.template', // alle abschnitt daten
+ 'ai.webResearch',
+ 'ai.summarizeDocument',
+ 'ai.translateDocument',
+ 'ai.convertDocument',
+ 'ai.generateDocument',
+ 'ai.generateCode', // alle KI ausser ai.prompt
+ 'sharepoint.listFiles', // dateien auflisten
+]);
+
+/** Default category display order */
+export const CATEGORY_ORDER = [
+ 'trigger',
+ 'input',
+ 'flow',
+ 'data',
+ 'ai',
+ 'email',
+ 'sharepoint',
+] 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
new file mode 100644
index 0000000..ae55f50
--- /dev/null
+++ b/src/components/Automation2FlowEditor/index.ts
@@ -0,0 +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..1f0d55a
--- /dev/null
+++ b/src/components/Automation2FlowEditor/utils.ts
@@ -0,0 +1,25 @@
+/**
+ * Automation2 Flow Editor - Utility functions
+ */
+
+import type React from 'react';
+import { CATEGORY_ICONS, DEFAULT_CATEGORY_ICON } from './categoryIcons';
+
+/** 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;
+}
+
+/** Function type for resolving localized labels */
+export type GetLabelFn = (text: string | Record | undefined, lang?: string) => string;
diff --git a/src/components/FolderTree/SharepointBrowseTree.tsx b/src/components/FolderTree/SharepointBrowseTree.tsx
new file mode 100644
index 0000000..c844f4b
--- /dev/null
+++ b/src/components/FolderTree/SharepointBrowseTree.tsx
@@ -0,0 +1,306 @@
+/**
+ * SharepointBrowseTree – Lazy-loading tree for SharePoint browse.
+ * Same look & feel as FolderTree (chevron, FaFolder/FaFolderOpen, styling).
+ * Loads children on expand via onLoadChildren(path).
+ */
+
+import React, { useState, useCallback, useEffect } from 'react';
+import { FaFolder, FaFolderOpen, FaChevronRight, FaGlobe } from 'react-icons/fa';
+import styles from './FolderTree.module.css';
+
+export interface BrowseEntry {
+ name: string;
+ path: string;
+ isFolder: boolean;
+ size?: number;
+ mimeType?: string;
+ metadata?: Record;
+}
+
+export interface SharepointBrowseTreeProps {
+ /** Root path (usually "/") - children loaded via onLoadChildren */
+ rootPath?: string;
+ /** Load children for a given path. Returns folders and files. */
+ onLoadChildren: (path: string) => Promise;
+ /** Called when user selects a file path */
+ onSelectFile: (path: string) => void;
+ /** Called when user selects a folder path (e.g. for destination). If provided, folder rows are selectable. */
+ onSelectFolder?: (path: string) => void;
+ /** Currently selected path (for highlight) */
+ selectedPath?: string | null;
+ /** Optional: pre-seed root children (e.g. from initial load) */
+ initialChildren?: BrowseEntry[];
+}
+
+function _fileIcon(mime?: string): string {
+ if (!mime) return '\uD83D\uDCC4';
+ if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
+ if (mime.includes('pdf')) return '\uD83D\uDCD5';
+ if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
+ if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
+ if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
+ if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
+ if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
+ return '\uD83D\uDCC4';
+}
+
+/* ── File row ──────────────────────────────────────────────────────────── */
+
+function _FileRow({
+ entry,
+ selectedPath,
+ onSelect,
+}: {
+ entry: BrowseEntry;
+ selectedPath: string | null | undefined;
+ onSelect: (path: string) => void;
+}) {
+ const isSelected = selectedPath === entry.path;
+
+ return (
+ onSelect(entry.path)}
+ title={entry.path}
+ >
+
+ {_fileIcon(entry.mimeType)}
+ {entry.name}
+ {entry.size != null && (
+
+ {(entry.size / 1024).toFixed(0)}K
+
+ )}
+
+ );
+}
+
+/* ── Folder row (expandable, lazy-loads children) ───────────────────────── */
+
+function _FolderRow({
+ entry,
+ selectedPath,
+ expandedPaths,
+ loadedChildren,
+ loadingPaths,
+ onToggle,
+ onSelectFile,
+ onSelectFolder,
+}: {
+ entry: BrowseEntry;
+ selectedPath: string | null | undefined;
+ expandedPaths: Set;
+ loadedChildren: Record;
+ loadingPaths: Set;
+ onToggle: (path: string) => void;
+ onSelectFile: (path: string) => void;
+ onSelectFolder?: (path: string) => void;
+}) {
+ const isExpanded = expandedPaths.has(entry.path);
+ const isSelected = selectedPath === entry.path;
+ const children = loadedChildren[entry.path] ?? [];
+ const folders = children.filter((c) => c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
+ const files = children.filter((c) => !c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
+ const isLoading = isExpanded && loadingPaths.has(entry.path);
+
+ const handleRowClick = (e: React.MouseEvent) => {
+ const target = e.target as HTMLElement;
+ if (target.closest(`.${styles.chevron}`)) return;
+ if (onSelectFolder) {
+ onSelectFolder(entry.path);
+ return;
+ }
+ onToggle(entry.path);
+ };
+
+ const handleChevronClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onToggle(entry.path);
+ };
+
+ return (
+
+
+
+
+
+
+ {isExpanded ? : }
+
+ {entry.name}
+ {isLoading && (
+ …
+ )}
+
+ {isExpanded && (
+
+ {isLoading ? (
+
+ Wird geladen…
+
+ ) : (
+ <>
+ {folders.map((child) => (
+ <_FolderRow
+ key={child.path}
+ entry={child}
+ selectedPath={selectedPath}
+ expandedPaths={expandedPaths}
+ loadedChildren={loadedChildren}
+ loadingPaths={loadingPaths}
+ onToggle={onToggle}
+ onSelectFile={onSelectFile}
+ onSelectFolder={onSelectFolder}
+ />
+ ))}
+ {files.map((child) => (
+ <_FileRow
+ key={child.path}
+ entry={child}
+ selectedPath={selectedPath}
+ onSelect={onSelectFile}
+ />
+ ))}
+ {children.length === 0 && (
+
+ Leer
+
+ )}
+ >
+ )}
+
+ )}
+
+ );
+}
+
+/* ── Root component ─────────────────────────────────────────────────────── */
+
+export function SharepointBrowseTree({
+ rootPath = '/',
+ onLoadChildren,
+ onSelectFile,
+ onSelectFolder,
+ selectedPath,
+ initialChildren = [],
+}: SharepointBrowseTreeProps) {
+ const [expandedPaths, setExpandedPaths] = useState>(new Set([rootPath]));
+ const [loadedChildren, setLoadedChildren] = useState>(() =>
+ initialChildren.length > 0 ? { [rootPath]: initialChildren } : {}
+ );
+ const [loadingPaths, setLoadingPaths] = useState>(new Set());
+
+ const loadPath = useCallback(
+ async (path: string) => {
+ setLoadingPaths((p) => new Set(p).add(path));
+ try {
+ const items = await onLoadChildren(path);
+ setLoadedChildren((prev) => ({ ...prev, [path]: items }));
+ } catch {
+ setLoadedChildren((prev) => ({ ...prev, [path]: [] }));
+ } finally {
+ setLoadingPaths((p) => {
+ const next = new Set(p);
+ next.delete(path);
+ return next;
+ });
+ }
+ },
+ [onLoadChildren]
+ );
+
+ const handleToggle = useCallback(
+ (path: string) => {
+ setExpandedPaths((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ loadPath(path);
+ }
+ return next;
+ });
+ },
+ [loadPath]
+ );
+
+ useEffect(() => {
+ if (rootPath in loadedChildren) return;
+ if (initialChildren.length > 0) return;
+ loadPath(rootPath);
+ }, [rootPath, initialChildren.length, loadPath]);
+
+ const rootItems = loadedChildren[rootPath] ?? [];
+ const rootLoading = loadingPaths.has(rootPath);
+ const rootFolders = rootItems.filter((e) => e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
+ const rootFiles = rootItems.filter((e) => !e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
+ const isRootExpanded = expandedPaths.has(rootPath);
+
+ return (
+
+
+ handleToggle(rootPath)}
+ >
+
+
+
+ SharePoint
+ {rootLoading && (
+ …
+ )}
+
+ {isRootExpanded && (
+
+ {rootLoading ? (
+
+ Sites werden geladen…
+
+ ) : (
+ <>
+ {rootFolders.map((entry) => (
+ <_FolderRow
+ key={entry.path}
+ entry={entry}
+ selectedPath={selectedPath}
+ expandedPaths={expandedPaths}
+ loadedChildren={loadedChildren}
+ loadingPaths={loadingPaths}
+ onToggle={handleToggle}
+ onSelectFile={onSelectFile}
+ onSelectFolder={onSelectFolder}
+ />
+ ))}
+ {rootFiles.map((entry) => (
+ <_FileRow
+ key={entry.path}
+ entry={entry}
+ selectedPath={selectedPath}
+ onSelect={onSelectFile}
+ />
+ ))}
+ {rootItems.length === 0 && !rootLoading && (
+
+ Keine Einträge
+
+ )}
+ >
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx
index da41d00..bd48c40 100644
--- a/src/config/pageRegistry.tsx
+++ b/src/config/pageRegistry.tsx
@@ -115,6 +115,10 @@ export const PAGE_ICONS: Record = {
'feature.realestate': ,
'feature.chatworkflow': ,
'feature.automation': ,
+ 'feature.automation2': ,
+ 'page.feature.automation2.editor': ,
+ 'page.feature.automation2.workflows': ,
+ 'page.feature.automation2.workflows-tasks': ,
'page.feature.chatbot.conversations': ,
'feature.chatbot': ,
'feature.teamsbot': ,
diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx
index 5ee085c..e3ebc03 100644
--- a/src/pages/FeatureView.tsx
+++ b/src/pages/FeatureView.tsx
@@ -6,6 +6,7 @@
*/
import React from 'react';
+import { Navigate, useParams } from 'react-router-dom';
import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
import { useLanguage } from '../providers/language/LanguageContext';
@@ -30,6 +31,11 @@ import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/r
// Automation Views
import { AutomationDefinitionsView, AutomationTemplatesView } from './views/automation';
+// Automation2 Views
+import { Automation2Page } from './views/automation2/Automation2Page';
+import { Automation2WorkflowsPage } from './views/automation2/Automation2WorkflowsPage';
+import { Automation2WorkflowsTasksPage } from './views/automation2/Automation2WorkflowsTasksPage';
+
// Workspace Views
import { WorkspacePage } from './views/workspace/WorkspacePage';
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
@@ -128,6 +134,11 @@ const VIEW_COMPONENTS: Record> = {
definitions: AutomationDefinitionsView,
templates: AutomationTemplatesView,
},
+ automation2: {
+ editor: Automation2Page,
+ workflows: Automation2WorkflowsPage,
+ 'workflows-tasks': Automation2WorkflowsTasksPage,
+ },
workspace: {
dashboard: WorkspacePage,
editor: WorkspaceEditorPage,
@@ -162,7 +173,13 @@ interface FeatureViewPageProps {
export const FeatureViewPage: React.FC = ({ view }) => {
const { instance, featureCode, isValid } = useCurrentInstance();
const { currentLanguage } = useLanguage();
-
+ const { mandateId, instanceId } = useParams<{ mandateId?: string; instanceId?: string }>();
+
+ // automation2: Dashboard entfernt → Index/Base-URL auf Editor umleiten
+ if (featureCode === 'automation2' && view === 'dashboard' && mandateId && instanceId) {
+ return ;
+ }
+
// Berechtigungs-Check
const viewCode = `${featureCode}-${view}`;
const canView = useCanViewFeatureView(viewCode);
diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx
index ac68e57..fd0e467 100644
--- a/src/pages/Store.tsx
+++ b/src/pages/Store.tsx
@@ -7,7 +7,7 @@
*/
import React from 'react';
-import { FaCogs, FaComments, FaHeadset } from 'react-icons/fa';
+import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
import { useLanguage } from '../providers/language/LanguageContext';
import { useStore } from '../hooks/useStore';
import type { StoreFeature } from '../api/storeApi';
@@ -15,6 +15,7 @@ import styles from './Store.module.css';
const FEATURE_ICONS: Record = {
automation: ,
+ automation2: ,
teamsbot: ,
workspace: ,
commcoach: ,
@@ -26,6 +27,11 @@ const FEATURE_DESCRIPTIONS: Record> = {
en: 'Create and manage automations to handle recurring tasks efficiently.',
fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.',
},
+ automation2: {
+ de: 'n8n-style Flow-Automatisierung mit grafischem Editor, RAG und Tools.',
+ en: 'n8n-style flow automation with visual editor, RAG and tools.',
+ fr: 'Automatisation de flux style n8n avec editeur visuel, RAG et outils.',
+ },
teamsbot: {
de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
diff --git a/src/pages/views/automation2/Automation2Page.tsx b/src/pages/views/automation2/Automation2Page.tsx
new file mode 100644
index 0000000..36b25d6
--- /dev/null
+++ b/src/pages/views/automation2/Automation2Page.tsx
@@ -0,0 +1,38 @@
+/**
+ * Automation2Page
+ *
+ * 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';
+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;
+
+ if (!instanceId) {
+ return (
+
+
Automation 2
+
Keine Feature-Instanz gefunden.
+
+ );
+ }
+
+ 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
new file mode 100644
index 0000000..6fcb79b
--- /dev/null
+++ b/src/pages/views/automation2/Automation2WorkflowsTasks.module.css
@@ -0,0 +1,281 @@
+.container {
+ padding: 1.5rem;
+ max-width: 900px;
+}
+
+.container h2 {
+ margin: 0 0 1rem 0;
+ 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;
+ align-items: center;
+ gap: 1rem;
+ padding: 3rem;
+ color: var(--text-secondary, #666);
+}
+
+.spinner {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.placeholder {
+ padding: 2rem;
+ text-align: center;
+ color: var(--text-secondary, #666);
+}
+
+.workflowList {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.workflowItem {
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 8px;
+ overflow: hidden;
+ background: var(--bg-primary, #fff);
+}
+
+.workflowHeader {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.75rem 1rem;
+ text-align: left;
+ background: var(--bg-secondary, #f8f9fa);
+ border: none;
+ cursor: pointer;
+ font-size: 1rem;
+}
+
+.workflowHeader:hover {
+ background: var(--bg-hover, #e9ecef);
+}
+
+.badge {
+ margin-left: auto;
+ background: var(--primary-color, #007bff);
+ color: white;
+ padding: 0.2rem 0.5rem;
+ border-radius: 12px;
+ font-size: 0.8rem;
+}
+
+.taskList {
+ padding: 1rem;
+ border-top: 1px solid var(--border-color, #e0e0e0);
+}
+
+.empty {
+ color: var(--text-tertiary, #999);
+ font-size: 0.9rem;
+ margin: 0;
+}
+
+.taskCard {
+ padding: 1rem;
+ margin-bottom: 0.75rem;
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 6px;
+ background: var(--bg-primary, #fff);
+}
+
+.taskCard:last-child {
+ margin-bottom: 0;
+}
+
+.taskType {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--text-secondary, #666);
+ margin-bottom: 0.5rem;
+}
+
+.formFields {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.formFields button {
+ margin-top: 0.75rem;
+ align-self: flex-start;
+}
+
+.formFields label,
+.taskCard label {
+ display: block;
+ font-size: 0.875rem;
+ margin-top: 0.5rem;
+ margin-bottom: 0.25rem;
+}
+
+.formFields input[type='text'],
+.formFields input[type='number'],
+.formFields input[type='date'],
+.taskCard input[type='text'],
+.taskCard input[type='number'],
+.taskCard textarea {
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 4px;
+}
+
+.taskCard textarea {
+ min-height: 80px;
+ margin-bottom: 0.5rem;
+}
+
+.openFormButton {
+ margin-top: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: var(--primary-color, #007bff);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ cursor: pointer;
+}
+
+.openFormButton:hover:not(:disabled) {
+ opacity: 0.9;
+}
+
+.openFormButton:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.popupSubmitButton {
+ padding: 0.5rem 1.25rem;
+ background: var(--success-color, #28a745);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ cursor: pointer;
+}
+
+.popupSubmitButton:hover:not(:disabled) {
+ opacity: 0.9;
+}
+
+.popupSubmitButton:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.approvalButtons {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+.approvalButtons button,
+.taskCard button {
+ padding: 0.5rem 1rem;
+ border-radius: 6px;
+ border: none;
+ cursor: pointer;
+ font-size: 0.9rem;
+}
+
+.approvalButtons button:first-child,
+.taskCard button[type='button'] {
+ background: var(--primary-color, #007bff);
+ color: white;
+}
+
+.approvalButtons button:last-of-type:not(:first-child) {
+ background: var(--danger-color, #dc3545);
+ color: white;
+}
+
+.approvalButtons button:disabled,
+.taskCard button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
diff --git a/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx b/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
new file mode 100644
index 0000000..3950d06
--- /dev/null
+++ b/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
@@ -0,0 +1,461 @@
+/**
+ * Automation2WorkflowsTasksPage
+ * 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 {
+ fetchTasks,
+ completeTask,
+ 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 [tasks, setTasks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [completedExpanded, setCompletedExpanded] = useState(false);
+ const [submitting, setSubmitting] = useState(null);
+
+ const load = useCallback(async () => {
+ if (!instanceId) return;
+ setLoading(true);
+ try {
+ const taskList = await fetchTasks(request, instanceId);
+ setTasks(taskList);
+ } catch (e) {
+ console.error('[Automation2] load failed', e);
+ } finally {
+ setLoading(false);
+ }
+ }, [instanceId, request]);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const handleComplete = async (taskId: string, result: Record) => {
+ if (!instanceId) return;
+ setSubmitting(taskId);
+ try {
+ await completeTask(request, instanceId, taskId, result);
+ await load();
+ } catch (e) {
+ console.error('[Automation2] complete failed', e);
+ } finally {
+ setSubmitting(null);
+ }
+ };
+
+ const openTasks = tasks.filter((t) => t.status === 'pending');
+ const completedTasks = tasks.filter((t) => t.status !== 'pending');
+
+ if (!instanceId) {
+ return (
+
+
Tasks
+
Keine Feature-Instanz gefunden.
+
+ );
+ }
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
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
+ />
+ ))
+ )}
+
+ )}
+
+
+ );
+};
+
+interface TaskCardProps {
+ task: Automation2Task;
+ onSubmit: (result: Record) => void;
+ submitting: boolean;
+ readOnly?: boolean;
+}
+
+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 requiredFields = fields.filter((f) => f.required);
+ const allRequiredFilled = requiredFields.every((f) => {
+ const v = formData[f.name];
+ if (f.type === 'boolean') return true;
+ return v !== undefined && v !== null && String(v).trim() !== '';
+ });
+ const formContent = (
+
+ );
+ return (
+ <>
+
+ setFormPopupOpen(false)}
+ size="medium"
+ footerContent={
+
+ }
+ >
+ {formContent}
+
+ >
+ );
+ }
+ case 'input.approval':
+ return (
+
+ {config.title != null && String(config.title) !== '' &&
{String(config.title)}
}
+ {config.description != null && String(config.description) !== '' &&
{String(config.description)}
}
+
+
+
+
+
+ );
+ case 'input.comment':
+ return (
+
+
+ );
+ case 'input.selection': {
+ const options =
+ (config.options as Array<{ value: string; label: string }>) ?? [];
+ const multiple = config.multiple as boolean;
+ return (
+
+ {options.map((o) => (
+
+ ))}
+
+
+ );
+ }
+ case 'input.confirmation':
+ return (
+
+
{(config.question as string) ?? 'Bestätigen?'}
+
+
+
+
+
+ );
+ case 'input.upload':
+ return (
+
+
Upload-Komponente – noch nicht implementiert
+
+
+ );
+ case 'input.review':
+ return (
+
+
Review – Content anzeigen + Feedback
+
+ );
+ default:
+ return (
+
+
Unbekannter Task-Typ: {nodeType}
+
+
+ );
+ }
+ };
+
+ return (
+
+
+
+ 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 2e34a91..0d695b7 100644
--- a/src/types/mandate.ts
+++ b/src/types/mandate.ts
@@ -261,6 +261,16 @@ export const FEATURE_REGISTRY: Record = {
{ code: 'logs', label: { de: 'Protokolle', en: 'Logs' }, path: 'logs' },
]
},
+ automation2: {
+ code: 'automation2',
+ label: { de: 'Automation 2', en: 'Automation 2' },
+ icon: 'sitemap',
+ views: [
+ { code: 'editor', label: { de: 'Editor', en: 'Editor' }, path: 'editor' },
+ { code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
+ { code: 'workflows-tasks', label: { de: 'Tasks', en: 'Tasks' }, path: 'workflows-tasks' },
+ ]
+ },
neutralization: {
code: 'neutralization',
label: { de: 'Neutralisierung', en: 'Neutralization', fr: 'Neutralisation' },