seperated files and updated editor

This commit is contained in:
idittrich-valueon 2026-03-22 16:40:42 +01:00
parent 9b4bad975c
commit 835428ac5f
30 changed files with 1650 additions and 846 deletions

View file

@ -157,6 +157,7 @@ function App() {
{/* Workspace + Automation2 Editor */} {/* Workspace + Automation2 Editor */}
<Route path="editor" element={<FeatureViewPage view="editor" />} /> <Route path="editor" element={<FeatureViewPage view="editor" />} />
{/* Automation2 Workflows & Tasks */} {/* Automation2 Workflows & Tasks */}
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} /> <Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
{/* Teams Bot Feature Views */} {/* Teams Bot Feature Views */}

View file

@ -81,6 +81,20 @@ export interface Automation2Workflow {
label: string; label: string;
graph: Automation2Graph; graph: Automation2Graph;
active?: boolean; active?: boolean;
/** Enriched: run count */
runCount?: number;
/** Enriched: has active (running/paused) run */
isRunning?: boolean;
/** Enriched: status of active run */
runStatus?: string;
/** Enriched: nodeId where workflow is stuck (paused) */
stuckAtNodeId?: string;
/** Enriched: human-readable label for stuck node */
stuckAtNodeLabel?: string;
/** Enriched: created timestamp (seconds) */
createdAt?: number;
/** Enriched: last run started timestamp (seconds) */
lastStartedAt?: number;
} }
// ============================================================================ // ============================================================================
@ -242,6 +256,12 @@ export interface Automation2Task {
config: Record<string, unknown>; config: Record<string, unknown>;
status: string; status: string;
result?: Record<string, unknown>; result?: Record<string, unknown>;
/** Workflow label (enriched by API) */
workflowLabel?: string;
/** Unix timestamp ms (from _createdAt) */
createdAt?: number;
/** Optional due date - configurable in future */
dueAt?: number;
} }
export async function fetchTasks( export async function fetchTasks(

View file

@ -224,17 +224,34 @@
position: relative; position: relative;
min-height: 100%; min-height: 100%;
height: 100%; height: 100%;
overflow: auto; overflow: hidden;
background-image: radial-gradient(circle, var(--border-color, #e0e0e0) 1px, transparent 1px);
background-size: 20px 20px;
border-radius: 8px; border-radius: 8px;
/* Infinite grid: on viewport, moves with pan/zoom via inline style */
background-image: radial-gradient(circle, var(--border-color, #e0e0e0) 1px, transparent 1px);
background-repeat: repeat;
}
.canvasContent {
position: absolute;
left: 0;
top: 0;
will-change: transform;
background: transparent;
}
.canvasGrab {
cursor: grab;
}
.canvasPanning {
cursor: grabbing;
user-select: none;
} }
.canvasPlaceholder { .canvasPlaceholder {
position: absolute; position: absolute;
left: 50%; left: 2rem;
top: 50%; top: 2rem;
transform: translate(-50%, -50%);
text-align: center; text-align: center;
color: var(--text-tertiary, #999); color: var(--text-tertiary, #999);
border: 2px dashed var(--border-color, #dee2e6); border: 2px dashed var(--border-color, #dee2e6);
@ -463,3 +480,20 @@
color: var(--text-secondary, #666); color: var(--text-secondary, #666);
cursor: pointer; cursor: pointer;
} }
.formFieldRemoveButton {
margin-left: auto;
padding: 0.25rem 0.4rem;
border: none;
background: transparent;
color: var(--text-tertiary, #999);
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
}
.formFieldRemoveButton:hover {
color: var(--danger-color, #dc3545);
background: rgba(220, 53, 69, 0.1);
}

View file

@ -2,22 +2,11 @@
* Automation2FlowEditor * Automation2FlowEditor
* *
* n8n-style flow builder with backend-driven node list. * n8n-style flow builder with backend-driven node list.
* Sidebar: all available node types (from API), grouped by category. * Composes: NodeSidebar, FlowCanvas, NodeConfigPanel, CanvasHeader.
* Canvas: placeholder for graph (drag nodes to add).
*/ */
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import { FaSpinner } from 'react-icons/fa';
FaPlay,
FaCodeBranch,
FaDatabase,
FaPlug,
FaUser,
FaSignInAlt,
FaSpinner,
FaChevronDown,
FaChevronRight,
} from 'react-icons/fa';
import { useApiRequest } from '../../hooks/useApi'; import { useApiRequest } from '../../hooks/useApi';
import { import {
fetchNodeTypes, fetchNodeTypes,
@ -34,54 +23,25 @@ import {
} from '../../api/automation2Api'; } from '../../api/automation2Api';
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas'; import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel'; import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader';
import { getCategoryIcon } from './utils';
import { fromApiGraph, toApiGraph } from './graphUtils';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
// Map category -> icon
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
trigger: <FaPlay />,
input: <FaUser />,
flow: <FaCodeBranch />,
data: <FaDatabase />,
io: <FaPlug />,
human: <FaUser />,
};
// I/O nodes: group by method/context (KI, Kontext, Outlook, SharePoint, Jira, Trustee, Chatbot)
const IO_METHOD_ORDER = ['ai', 'context', 'outlook', 'sharepoint', 'jira', 'trustee', 'chatbot'];
const IO_METHOD_LABELS: Record<string, Record<string, string>> = {
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' },
};
function getCategoryIcon(categoryId: string): React.ReactNode {
return CATEGORY_ICONS[categoryId] ?? <FaPlug />;
}
function getIoMethodLabel(method: string, lang: string): string {
return IO_METHOD_LABELS[method]?.[lang] ?? IO_METHOD_LABELS[method]?.en ?? method;
}
const LOG = '[Automation2]'; const LOG = '[Automation2]';
function getLabel(text: string | Record<string, string> | undefined, lang = 'de'): string {
if (!text) return '';
if (typeof text === 'string') return text;
return (text as Record<string, string>)[lang] ?? (text as Record<string, string>).en ?? '';
}
interface Automation2FlowEditorProps { interface Automation2FlowEditorProps {
instanceId: string; instanceId: string;
language?: string; language?: string;
/** When set, load this workflow on mount (e.g. from workflows list edit) */
initialWorkflowId?: string | null;
} }
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
instanceId, instanceId,
language = 'de', language = 'de',
initialWorkflowId,
}) => { }) => {
const { request } = useApiRequest(); const { request } = useApiRequest();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]); const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
@ -102,93 +62,37 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null); const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const fromApiGraph = useCallback((graph: Automation2Graph): { nodes: CanvasNode[]; connections: CanvasConnection[] } => { const handleFromApiGraph = useCallback(
const nodeMap = new Map<string, { inputs: number; outputs: number }>(); (graph: Automation2Graph) => {
nodeTypes.forEach((nt) => { const { nodes, connections } = fromApiGraph(graph, nodeTypes);
nodeMap.set(nt.id, { inputs: nt.inputs ?? 1, outputs: nt.outputs ?? 1 }); setCanvasNodes(nodes);
}); setCanvasConnections(connections);
const nodes: CanvasNode[] = (graph.nodes || []).map((n) => { },
const io = nodeMap.get(n.type) ?? { inputs: 1, outputs: 1 }; [nodeTypes]
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 };
}, [nodeTypes]);
const toApiGraph = useCallback((): Automation2Graph => {
const nodeMap = new Map(canvasNodes.map((n) => [n.id, n]));
const graph = {
nodes: canvasNodes.map((n) => ({
id: n.id,
type: n.type,
x: n.x,
y: n.y,
title: n.title,
comment: n.comment,
parameters: n.parameters ?? {},
})),
connections: canvasConnections.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,
};
}),
};
console.log(`${LOG} toApiGraph: canvasNodes=${canvasNodes.length} canvasConnections=${canvasConnections.length} ->`, graph);
return graph;
}, [canvasNodes, canvasConnections]);
const handleExecute = useCallback(async () => { const handleExecute = useCallback(async () => {
console.log(`${LOG} handleExecute: start`); const graph = toApiGraph(canvasNodes, canvasConnections);
const graph = toApiGraph();
if (graph.nodes.length === 0) { if (graph.nodes.length === 0) {
console.warn(`${LOG} handleExecute: keine Nodes, abbrechen`);
setExecuteResult({ success: false, error: 'Keine Nodes im Workflow.' }); setExecuteResult({ success: false, error: 'Keine Nodes im Workflow.' });
return; return;
} }
setExecuting(true); setExecuting(true);
setExecuteResult(null); setExecuteResult(null);
console.log(`${LOG} handleExecute: rufe executeGraph auf instanceId=${instanceId}`);
try { try {
const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined); const result = await executeGraph(
console.log(`${LOG} handleExecute: fertig success=${result?.success}`, result); request,
instanceId,
graph,
currentWorkflowId ?? undefined
);
setExecuteResult(result); setExecuteResult(result);
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err); setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
console.error(`${LOG} handleExecute: Fehler`, err);
setExecuteResult({ success: false, error: msg });
} finally { } finally {
setExecuting(false); setExecuting(false);
console.log(`${LOG} handleExecute: end`);
} }
}, [request, instanceId, toApiGraph, currentWorkflowId]); }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]);
const loadWorkflows = useCallback(async () => { const loadWorkflows = useCallback(async () => {
if (!instanceId) return; if (!instanceId) return;
@ -201,7 +105,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
}, [instanceId, request]); }, [instanceId, request]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
const graph = toApiGraph(); const graph = toApiGraph(canvasNodes, canvasConnections);
if (graph.nodes.length === 0) { if (graph.nodes.length === 0) {
setExecuteResult({ success: false, error: 'Keine Nodes zum Speichern.' }); setExecuteResult({ success: false, error: 'Keine Nodes zum Speichern.' });
return; return;
@ -219,28 +123,39 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
setExecuteResult({ success: true } as ExecuteGraphResponse); setExecuteResult({ success: true } as ExecuteGraphResponse);
} }
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err); setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
setExecuteResult({ success: false, error: msg });
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [request, instanceId, toApiGraph, currentWorkflowId]); }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]);
const handleLoad = useCallback(async (workflowId: string) => { const handleLoad = useCallback(
async (workflowId: string) => {
try { try {
const wf = await fetchWorkflow(request, instanceId, workflowId); const wf = await fetchWorkflow(request, instanceId, workflowId);
const graph = wf.graph; if (wf.graph) handleFromApiGraph(wf.graph);
if (graph) {
const { nodes, connections } = fromApiGraph(graph);
setCanvasNodes(nodes);
setCanvasConnections(connections);
setCurrentWorkflowId(wf.id);
}
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err); setExecuteResult({
setExecuteResult({ success: false, error: msg }); success: false,
error: err instanceof Error ? err.message : String(err),
});
} }
}, [request, instanceId, fromApiGraph]); },
[request, instanceId, handleFromApiGraph]
);
const handleWorkflowSelect = useCallback(
(workflowId: string | null) => {
setCurrentWorkflowId(workflowId);
if (workflowId) handleLoad(workflowId);
else {
setCanvasNodes([]);
setCanvasConnections([]);
setExecuteResult(null);
}
},
[handleLoad]
);
const handleNew = useCallback(() => { const handleNew = useCallback(() => {
setCanvasNodes([]); setCanvasNodes([]);
@ -255,23 +170,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
const loadNodeTypes = useCallback(async () => { const loadNodeTypes = useCallback(async () => {
if (!instanceId) return; if (!instanceId) return;
console.log(`${LOG} loadNodeTypes: start instanceId=${instanceId} language=${language}`);
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await fetchNodeTypes(request, instanceId, language); const data = await fetchNodeTypes(request, instanceId, language);
setNodeTypes(data.nodeTypes); setNodeTypes(data.nodeTypes);
setCategories(data.categories); setCategories(data.categories);
console.log(`${LOG} loadNodeTypes: ok ${data.nodeTypes.length} types, ${data.categories.length} categories`);
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err); setError(err instanceof Error ? err.message : String(err));
console.error(`${LOG} loadNodeTypes: Fehler`, err);
setError(msg);
setNodeTypes([]); setNodeTypes([]);
setCategories([]); setCategories([]);
} finally { } finally {
setLoading(false); setLoading(false);
console.log(`${LOG} loadNodeTypes: end`);
} }
}, [instanceId, language, request]); }, [instanceId, language, request]);
@ -283,63 +193,29 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
loadWorkflows(); loadWorkflows();
}, [loadWorkflows]); }, [loadWorkflows]);
const toggleCategory = (id: string) => { useEffect(() => {
if (initialWorkflowId && workflows.length > 0 && !currentWorkflowId) {
handleWorkflowSelect(initialWorkflowId);
}
}, [initialWorkflowId, workflows, currentWorkflowId, handleWorkflowSelect]);
const toggleCategory = useCallback((id: string) => {
setExpandedCategories((prev) => { setExpandedCategories((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
else next.add(id); else next.add(id);
return next; return next;
}); });
}; }, []);
const toggleIoMethod = (method: string) => { const toggleIoMethod = useCallback((method: string) => {
setExpandedIoMethods((prev) => { setExpandedIoMethods((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(method)) next.delete(method); if (next.has(method)) next.delete(method);
else next.add(method); else next.add(method);
return next; return next;
}); });
}; }, []);
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<string, NodeType[]> = {};
filteredNodeTypes.forEach((n) => {
const cat = n.category || 'other';
if (!map[cat]) map[cat] = [];
map[cat].push(n);
});
return map;
}, [filteredNodeTypes]);
// For io category: sub-group by method (KI, Kontext, Outlook, etc.)
const ioSubGroups = useMemo(() => {
const ioNodes = groupedByCategory['io'] || [];
const byMethod: Record<string, NodeType[]> = {};
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[] }> = [];
IO_METHOD_ORDER.forEach((m) => {
if (byMethod[m]?.length) ordered.push({ method: m, nodes: byMethod[m] });
});
Object.keys(byMethod).forEach((m) => {
if (!IO_METHOD_ORDER.includes(m)) ordered.push({ method: m, nodes: byMethod[m] });
});
return ordered;
}, [groupedByCategory]);
const handleDropNodeType = useCallback( const handleDropNodeType = useCallback(
(nodeTypeId: string, x: number, y: number) => { (nodeTypeId: string, x: number, y: number) => {
@ -367,25 +243,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
[nodeTypes, language] [nodeTypes, language]
); );
const orderedCategories = useMemo(() => { const renderSidebar = () => {
const order = ['trigger', 'input', 'flow', 'data', 'io'];
const seen = new Set<string>();
const result: string[] = [];
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]);
if (loading) { if (loading) {
return ( return (
<div className={styles.container}>
<div className={styles.sidebar}> <div className={styles.sidebar}>
<div className={styles.sidebarHeader}> <div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3> <h3 className={styles.sidebarTitle}>Nodes</h3>
@ -395,16 +255,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
<p>Lade Node-Typen...</p> <p>Lade Node-Typen...</p>
</div> </div>
</div> </div>
<div className={styles.canvas}>
<div className={styles.canvasArea} />
</div>
</div>
); );
} }
if (error) { if (error) {
return ( return (
<div className={styles.container}>
<div className={styles.sidebar}> <div className={styles.sidebar}>
<div className={styles.sidebarHeader}> <div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3> <h3 className={styles.sidebarTitle}>Nodes</h3>
@ -416,235 +270,40 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
</button> </button>
</div> </div>
</div> </div>
<div className={styles.canvas}>
<div className={styles.canvasArea} />
</div>
</div>
); );
} }
return (
<NodeSidebar
nodeTypes={nodeTypes}
categories={categories}
filter={filter}
onFilterChange={setFilter}
language={language}
expandedCategories={expandedCategories}
expandedIoMethods={expandedIoMethods}
onToggleCategory={toggleCategory}
onToggleIoMethod={toggleIoMethod}
/>
);
};
return ( return (
<div className={styles.container}> <div className={styles.container}>
{/* Sidebar */} {renderSidebar()}
<div className={styles.sidebar}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3>
<input
type="text"
className={styles.sidebarSearch}
placeholder="Nodes durchsuchen..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className={styles.nodeList}>
{orderedCategories.map((catId) => {
const isExpanded = expandedCategories.has(catId);
const catLabel = categories.find((c) => c.id === catId);
const label = getLabel(catLabel?.label, language) || catId;
// I/O category: render sub-groups directly (KI, Kontext, Outlook, etc.) keine E/A-Überschrift
if (catId === 'io' && ioSubGroups.length > 0) {
return (
<React.Fragment key={catId}>
{ioSubGroups.map(({ method, nodes }) => {
const methodLabel = getIoMethodLabel(method, language);
const isMethodExpanded = expandedIoMethods.has(method);
return (
<div key={`io-${method}`} className={styles.categoryGroup}>
<button
type="button"
className={styles.categoryHeader}
onClick={() => toggleIoMethod(method)}
>
{isMethodExpanded ? (
<FaChevronDown className={styles.categoryIcon} />
) : (
<FaChevronRight className={styles.categoryIcon} />
)}
<span className={styles.categoryLabel}>{methodLabel}</span>
<span className={styles.categoryCount}>{nodes.length}</span>
</button>
{isMethodExpanded &&
nodes.map((node) => (
<div
key={node.id}
className={styles.nodeItem}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(
'application/json',
JSON.stringify({ type: node.id })
);
e.dataTransfer.effectAllowed = 'copy';
}}
>
<div
className={styles.nodeItemIcon}
style={{
backgroundColor: node.meta?.color
? `${node.meta.color}20`
: 'var(--bg-tertiary, #e9ecef)',
color: node.meta?.color ?? 'var(--text-secondary, #666)',
}}
>
{getCategoryIcon(node.category)}
</div>
<div className={styles.nodeItemInfo}>
<span className={styles.nodeItemLabel}>
{getLabel(node.label, language)}
</span>
<span className={styles.nodeItemDesc}>
{getLabel(node.description, language)}
</span>
</div>
</div>
))}
</div>
);
})}
</React.Fragment>
);
}
const items = groupedByCategory[catId] || [];
return (
<div key={catId} className={styles.categoryGroup}>
<button
type="button"
className={styles.categoryHeader}
onClick={() => toggleCategory(catId)}
>
{isExpanded ? (
<FaChevronDown className={styles.categoryIcon} />
) : (
<FaChevronRight className={styles.categoryIcon} />
)}
<span className={styles.categoryLabel}>{label}</span>
<span className={styles.categoryCount}>{items.length}</span>
</button>
{isExpanded &&
items.map((node) => (
<div
key={node.id}
className={styles.nodeItem}
draggable
onDragStart={(e) => {
e.dataTransfer.setData(
'application/json',
JSON.stringify({ type: node.id })
);
e.dataTransfer.effectAllowed = 'copy';
}}
>
<div
className={styles.nodeItemIcon}
style={{
backgroundColor: node.meta?.color
? `${node.meta.color}20`
: 'var(--bg-tertiary, #e9ecef)',
color: node.meta?.color ?? 'var(--text-secondary, #666)',
}}
>
{getCategoryIcon(node.category)}
</div>
<div className={styles.nodeItemInfo}>
<span className={styles.nodeItemLabel}>
{getLabel(node.label, language)}
</span>
<span className={styles.nodeItemDesc}>
{getLabel(node.description, language)}
</span>
</div>
</div>
))}
</div>
);
})}
</div>
</div>
{/* Canvas */}
<div className={styles.canvas}> <div className={styles.canvas}>
<div className={styles.canvasHeader}> <CanvasHeader
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}> workflows={workflows}
<h4 className={styles.canvasTitle} style={{ margin: 0 }}>Workflow-Editor</h4> currentWorkflowId={currentWorkflowId}
<button type="button" className={styles.retryButton} onClick={handleNew}> onWorkflowSelect={handleWorkflowSelect}
Neu onNew={handleNew}
</button> onSave={handleSave}
<button onExecute={handleExecute}
type="button" saving={saving}
className={styles.retryButton} executing={executing}
onClick={handleSave} hasNodes={canvasNodes.length > 0}
disabled={saving || canvasNodes.length === 0} executeResult={executeResult}
> />
{saving ? <FaSpinner className={styles.spinner} /> : 'Speichern'}
</button>
<select
value={currentWorkflowId ?? ''}
onChange={(e) => {
const id = e.target.value || null;
setCurrentWorkflowId(id);
if (id) handleLoad(id);
else handleNew();
}}
style={{ padding: '0.4rem', minWidth: 180 }}
>
<option value=""> Workflow laden </option>
{workflows.map((w) => (
<option key={w.id} value={w.id}>
{w.label}
</option>
))}
</select>
<button
type="button"
className={styles.retryButton}
onClick={handleExecute}
disabled={executing || canvasNodes.length === 0}
>
{executing ? (
<>
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
Ausführen
</>
) : (
<>
<FaPlay style={{ marginRight: '0.5rem' }} />
Ausführen
</>
)}
</button>
</div>
{executeResult && (
<div
style={{
marginTop: '0.5rem',
padding: '0.5rem',
borderRadius: 6,
fontSize: '0.875rem',
background: executeResult.success
? 'rgba(40,167,69,0.15)'
: (executeResult as { paused?: boolean }).paused
? 'rgba(0,123,255,0.15)'
: 'rgba(220,53,69,0.15)',
color: executeResult.success
? 'var(--success-color,#28a745)'
: (executeResult as { paused?: boolean }).paused
? 'var(--primary-color,#007bff)'
: 'var(--danger-color,#dc3545)',
}}
>
{executeResult.success ? (
<> Ausführung abgeschlossen.</>
) : (executeResult as { paused?: boolean }).paused ? (
<> Workflow pausiert. Öffne <strong>Workflows & Tasks</strong> in der Sidebar, um den Task zu bearbeiten.</>
) : (
<> {executeResult.error ?? 'Unbekannter Fehler'}</>
)}
</div>
)}
</div>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}> <div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<FlowCanvas <FlowCanvas

View file

@ -0,0 +1,117 @@
/**
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen) and execute result.
*/
import React from 'react';
import { FaPlay, FaSpinner } from 'react-icons/fa';
import type { Automation2Workflow, ExecuteGraphResponse } from '../../api/automation2Api';
import styles from './Automation2FlowEditor.module.css';
interface CanvasHeaderProps {
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
onWorkflowSelect: (workflowId: string | null) => void;
onNew: () => void;
onSave: () => void;
onExecute: () => void;
saving: boolean;
executing: boolean;
hasNodes: boolean;
executeResult: ExecuteGraphResponse | null;
}
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
workflows,
currentWorkflowId,
onWorkflowSelect,
onNew,
onSave,
onExecute,
saving,
executing,
hasNodes,
executeResult,
}) => (
<div className={styles.canvasHeader}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<h4 className={styles.canvasTitle} style={{ margin: 0 }}>
Workflow-Editor
</h4>
<button type="button" className={styles.retryButton} onClick={onNew}>
Neu
</button>
<button
type="button"
className={styles.retryButton}
onClick={onSave}
disabled={saving || !hasNodes}
>
{saving ? <FaSpinner className={styles.spinner} /> : 'Speichern'}
</button>
<select
value={currentWorkflowId ?? ''}
onChange={(e) => {
const id = e.target.value ? e.target.value : null;
onWorkflowSelect(id);
}}
style={{ padding: '0.4rem', minWidth: 180 }}
>
<option value=""> Workflow laden </option>
{workflows.map((w) => (
<option key={w.id} value={w.id}>
{w.label}
</option>
))}
</select>
<button
type="button"
className={styles.retryButton}
onClick={onExecute}
disabled={executing || !hasNodes}
>
{executing ? (
<>
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
Ausführen
</>
) : (
<>
<FaPlay style={{ marginRight: '0.5rem' }} />
Ausführen
</>
)}
</button>
</div>
{executeResult && (
<div
style={{
marginTop: '0.5rem',
padding: '0.5rem',
borderRadius: 6,
fontSize: '0.875rem',
background: executeResult.success
? 'rgba(40,167,69,0.15)'
: (executeResult as { paused?: boolean }).paused
? 'rgba(0,123,255,0.15)'
: 'rgba(220,53,69,0.15)',
color: executeResult.success
? 'var(--success-color,#28a745)'
: (executeResult as { paused?: boolean }).paused
? 'var(--primary-color,#007bff)'
: 'var(--danger-color,#dc3545)',
}}
>
{executeResult.success ? (
<> Ausführung abgeschlossen.</>
) : (executeResult as { paused?: boolean }).paused ? (
<>
Workflow pausiert. Öffne <strong>Workflows & Tasks</strong> in der Sidebar, um den
Task zu bearbeiten.
</>
) : (
<> {executeResult.error ?? 'Unbekannter Fehler'}</>
)}
</div>
)}
</div>
);

View file

@ -69,7 +69,20 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
} | null>(null); } | null>(null);
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null); const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null); const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null);
const [dragOffset, setDragOffset] = useState({ dx: 0, dy: 0 }); const [dragOffset, setDragOffset] = useState({
startClientX: 0,
startClientY: 0,
startNodeX: 0,
startNodeY: 0,
});
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [panning, setPanning] = useState<{
startX: number;
startY: number;
startPanX: number;
startPanY: number;
} | null>(null);
const nodeTypeMap = useMemo(() => { const nodeTypeMap = useMemo(() => {
const m: Record<string, NodeType> = {}; const m: Record<string, NodeType> = {};
@ -135,12 +148,12 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
const { type } = JSON.parse(raw); const { type } = JSON.parse(raw);
const el = containerRef.current; const el = containerRef.current;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left + el.scrollLeft - NODE_WIDTH / 2; const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2;
const y = e.clientY - rect.top + el.scrollTop - NODE_HEIGHT / 2; const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2;
onDropNodeType(type, Math.max(0, x), Math.max(0, y)); onDropNodeType(type, Math.max(0, x), Math.max(0, y));
} catch (_) {} } catch (_) {}
}, },
[onDropNodeType] [onDropNodeType, panOffset, zoom]
); );
const handleHandleMouseDown = useCallback( const handleHandleMouseDown = useCallback(
@ -206,16 +219,23 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
const node = nodes.find((n) => n.id === nodeId); const node = nodes.find((n) => n.id === nodeId);
if (!node) return; if (!node) return;
setDraggingNodeId(nodeId); setDraggingNodeId(nodeId);
setDragOffset({ dx: e.clientX - node.x, dy: e.clientY - node.y }); setDragOffset({
startClientX: e.clientX,
startClientY: e.clientY,
startNodeX: node.x,
startNodeY: node.y,
});
}, [nodes]); }, [nodes]);
React.useEffect(() => { React.useEffect(() => {
if (!draggingNodeId) return; if (!draggingNodeId) return;
const onMove = (e: MouseEvent) => { const onMove = (e: MouseEvent) => {
const dx = (e.clientX - dragOffset.startClientX) / zoom;
const dy = (e.clientY - dragOffset.startClientY) / zoom;
onNodesChange( onNodesChange(
nodes.map((n) => nodes.map((n) =>
n.id === draggingNodeId n.id === draggingNodeId
? { ...n, x: e.clientX - dragOffset.dx, y: e.clientY - dragOffset.dy } ? { ...n, x: dragOffset.startNodeX + dx, y: dragOffset.startNodeY + dy }
: n : n
) )
); );
@ -227,41 +247,79 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
window.removeEventListener('mousemove', onMove); window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp); window.removeEventListener('mouseup', onUp);
}; };
}, [draggingNodeId, dragOffset, nodes, onNodesChange]); }, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom]);
const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0, scrollLeft: 0, scrollTop: 0 }); const handleCanvasMouseDown = useCallback((e: React.MouseEvent) => {
const hitNode = (e.target as HTMLElement).closest(`.${styles.canvasNode}`);
if (hitNode || connectingFrom) return;
setPanning({
startX: e.clientX,
startY: e.clientY,
startPanX: panOffset.x,
startPanY: panOffset.y,
});
}, [connectingFrom, panOffset]);
const handleWheel = useCallback((e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoom((z) => Math.min(2, Math.max(0.25, z + delta)));
}, []);
React.useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel);
}, [handleWheel]);
React.useEffect(() => {
if (!panning) return;
const onMove = (e: MouseEvent) => {
setPanOffset({
x: panning.startPanX + (e.clientX - panning.startX),
y: panning.startPanY + (e.clientY - panning.startY),
});
};
const onUp = () => setPanning(null);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [panning]);
const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0 });
React.useEffect(() => { React.useEffect(() => {
const el = containerRef.current; const el = containerRef.current;
if (!el) return; if (!el) return;
const update = () => { const update = () => {
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
setContainerBounds({ left: r.left, top: r.top, scrollLeft: el.scrollLeft, scrollTop: el.scrollTop }); setContainerBounds({ left: r.left, top: r.top });
}; };
update(); update();
el.addEventListener('scroll', update);
window.addEventListener('resize', update); window.addEventListener('resize', update);
return () => { return () => window.removeEventListener('resize', update);
el.removeEventListener('scroll', update);
window.removeEventListener('resize', update);
};
}, []); }, []);
const CANVAS_SIZE = 8000;
const svgBounds = useMemo(() => { const svgBounds = useMemo(() => {
if (nodes.length === 0) return { width: 2000, height: 1500 }; if (nodes.length === 0) return { width: CANVAS_SIZE, height: CANVAS_SIZE };
let maxX = 0, maxY = 0; let maxX = 0, maxY = 0;
nodes.forEach((n) => { nodes.forEach((n) => {
maxX = Math.max(maxX, n.x + NODE_WIDTH + 100); maxX = Math.max(maxX, n.x + NODE_WIDTH + 200);
maxY = Math.max(maxY, n.y + NODE_HEIGHT + 100); maxY = Math.max(maxY, n.y + NODE_HEIGHT + 200);
}); });
return { width: Math.max(maxX, 2000), height: Math.max(maxY, 1500) }; return { width: Math.max(maxX, CANVAS_SIZE), height: Math.max(maxY, CANVAS_SIZE) };
}, [nodes]); }, [nodes]);
const screenToSvg = useCallback( const screenToSvg = useCallback(
(clientX: number, clientY: number) => ({ (clientX: number, clientY: number) => ({
x: clientX - containerBounds.left + containerBounds.scrollLeft, x: (clientX - containerBounds.left - panOffset.x) / zoom,
y: clientY - containerBounds.top + containerBounds.scrollTop, y: (clientY - containerBounds.top - panOffset.y) / zoom,
}), }),
[containerBounds] [containerBounds, panOffset, zoom]
); );
const handleDeleteNode = useCallback(() => { const handleDeleteNode = useCallback(() => {
@ -300,11 +358,25 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={styles.canvasDropZone} className={`${styles.canvasDropZone} ${panning ? styles.canvasPanning : styles.canvasGrab}`}
style={{
backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
backgroundPosition: `${-panOffset.x}px ${-panOffset.y}px`,
}}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop} onDrop={handleDrop}
onMouseDown={handleCanvasMouseDown}
tabIndex={0} tabIndex={0}
onClick={() => setSelectedNodeId(null)} onClick={() => setSelectedNodeId(null)}
>
<div
className={styles.canvasContent}
style={{
width: svgBounds.width,
height: svgBounds.height,
transform: `translate(${panOffset.x}px, ${panOffset.y}px) scale(${zoom})`,
transformOrigin: '0 0',
}}
> >
<svg <svg
className={styles.connectionsLayer} className={styles.connectionsLayer}
@ -504,5 +576,6 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
</div> </div>
)} )}
</div> </div>
</div>
); );
}; };

View file

@ -1,16 +1,15 @@
/** /**
* NodeConfigPanel - Configures parameters for input/human nodes. * NodeConfigPanel - Configures parameters for input/human nodes.
* Form fields: draggable, required toggle, layout ohne clipping. * Delegates to config components from configs/.
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { FaGripVertical } from 'react-icons/fa';
import type { CanvasNode } from './FlowCanvas'; import type { CanvasNode } from './FlowCanvas';
import type { NodeType } from '../../api/automation2Api'; import type { NodeType } from '../../api/automation2Api';
import { getLabel } from './utils';
import { NODE_CONFIG_REGISTRY } from './configs';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
type FormField = { name?: string; type?: string; label?: string; required?: boolean };
interface NodeConfigPanelProps { interface NodeConfigPanelProps {
node: CanvasNode | null; node: CanvasNode | null;
nodeType: NodeType | undefined; nodeType: NodeType | undefined;
@ -37,282 +36,21 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
}; };
if (!node || !node.type.startsWith('input.')) return null; if (!node || !node.type.startsWith('input.')) return null;
const nt = nodeType;
const getLabel = (text: string | Record<string, string> | undefined) => {
if (!text) return '';
if (typeof text === 'string') return text;
return (text as Record<string, string>)[language] ?? (text as Record<string, string>).en ?? '';
};
const renderConfig = () => { const ConfigRenderer = NODE_CONFIG_REGISTRY[node.type];
switch (node.type) { if (!ConfigRenderer) {
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 ( return (
<div> <div className={styles.nodeConfigPanel}>
<label>Felder</label> <h4>{getLabel(nodeType?.label, language) || node.type}</h4>
<div className={styles.formFieldsList}> <p>Keine Konfiguration für {node.type}</p>
{fields.map((f, i) => (
<div
key={i}
className={styles.formFieldRow}
onDragOver={(e) => {
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);
}}
>
<div className={styles.formFieldRowHeader}>
<span
className={styles.formFieldDragHandle}
title="Zum Verschieben ziehen"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', String(i));
e.dataTransfer.effectAllowed = 'move';
}}
>
<FaGripVertical />
</span>
<div className={styles.formFieldInputs}>
<input
placeholder="name"
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], name: e.target.value };
updateParam('fields', next);
}}
/>
<input
placeholder="label"
value={f.label ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], label: e.target.value };
updateParam('fields', next);
}}
/>
</div>
</div>
<div className={styles.formFieldRowFooter}>
<select
value={f.type ?? 'string'}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], type: e.target.value };
updateParam('fields', next);
}}
style={{ width: 'auto', minWidth: 90 }}
>
<option value="string">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">Checkbox</option>
</select>
<label className={styles.formFieldRequiredLabel}>
<input
type="checkbox"
checked={f.required ?? false}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], required: e.target.checked };
updateParam('fields', next);
}}
/>
Pflichtfeld
</label>
</div>
</div>
))}
<button
type="button"
onClick={() => updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])}
>
+ Feld
</button>
</div>
</div> </div>
); );
} }
case 'input.approval':
return (
<>
<div>
<label>Titel</label>
<input
value={(params.title as string) ?? ''}
onChange={(e) => updateParam('title', e.target.value)}
placeholder="Genehmigungstitel"
/>
</div>
<div>
<label>Beschreibung</label>
<textarea
value={(params.description as string) ?? ''}
onChange={(e) => updateParam('description', e.target.value)}
placeholder="Was genehmigt werden soll"
/>
</div>
</>
);
case 'input.upload':
return (
<>
<div>
<label>Accept (MIME)</label>
<input
value={(params.accept as string) ?? ''}
onChange={(e) => updateParam('accept', e.target.value)}
placeholder=".pdf,image/*"
/>
</div>
<div>
<label>Max Größe (MB)</label>
<input
type="number"
value={(params.maxSize as number) ?? 10}
onChange={(e) => updateParam('maxSize', parseFloat(e.target.value) || 0)}
/>
</div>
<div>
<label>
<input
type="checkbox"
checked={(params.multiple as boolean) ?? false}
onChange={(e) => updateParam('multiple', e.target.checked)}
/>
Mehrere Dateien
</label>
</div>
</>
);
case 'input.comment':
return (
<>
<div>
<label>Platzhalter</label>
<input
value={(params.placeholder as string) ?? ''}
onChange={(e) => updateParam('placeholder', e.target.value)}
placeholder="Kommentar eingeben..."
/>
</div>
<div>
<label>
<input
type="checkbox"
checked={(params.required as boolean) ?? true}
onChange={(e) => updateParam('required', e.target.checked)}
/>
Pflichtfeld
</label>
</div>
</>
);
case 'input.review':
return (
<>
<div>
<label>Content-Referenz</label>
<input
value={(params.contentRef as string) ?? ''}
onChange={(e) => updateParam('contentRef', e.target.value)}
placeholder="{{nodeId.field}}"
/>
</div>
</>
);
case 'input.selection': {
const options = (params.options as Array<{ value?: string; label?: string }>) ?? [];
return (
<div>
<label>Optionen</label>
{options.map((o, i) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<input
placeholder="value"
value={o.value ?? ''}
onChange={(e) => {
const next = [...options];
next[i] = { ...next[i], value: e.target.value };
updateParam('options', next);
}}
/>
<input
placeholder="label"
value={o.label ?? ''}
onChange={(e) => {
const next = [...options];
next[i] = { ...next[i], label: e.target.value };
updateParam('options', next);
}}
/>
</div>
))}
<button type="button" onClick={() => updateParam('options', [...options, { value: '', label: '' }])}>
+ Option
</button>
<div>
<label>
<input
type="checkbox"
checked={(params.multiple as boolean) ?? false}
onChange={(e) => updateParam('multiple', e.target.checked)}
/>
Mehrfachauswahl
</label>
</div>
</div>
);
}
case 'input.confirmation':
return (
<>
<div>
<label>Frage</label>
<input
value={(params.question as string) ?? ''}
onChange={(e) => updateParam('question', e.target.value)}
placeholder="Möchten Sie bestätigen?"
/>
</div>
<div>
<label>Bestätigen-Button</label>
<input
value={(params.confirmLabel as string) ?? 'Confirm'}
onChange={(e) => updateParam('confirmLabel', e.target.value)}
/>
</div>
<div>
<label>Ablehnen-Button</label>
<input
value={(params.rejectLabel as string) ?? 'Reject'}
onChange={(e) => updateParam('rejectLabel', e.target.value)}
/>
</div>
</>
);
default:
return <p>Keine Konfiguration für {node.type}</p>;
}
};
return ( return (
<div className={styles.nodeConfigPanel}> <div className={styles.nodeConfigPanel}>
<h4>{getLabel(nt?.label) || node.type}</h4> <h4>{getLabel(nodeType?.label, language) || node.type}</h4>
{renderConfig()} <ConfigRenderer params={params} updateParam={updateParam} />
</div> </div>
); );
}; };

View file

@ -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<NodeListItemProps> = ({
node,
language,
getLabel,
getCategoryIcon: getIcon = getCategoryIcon,
}) => (
<div
className={styles.nodeItem}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('application/json', JSON.stringify({ type: node.id }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<div
className={styles.nodeItemIcon}
style={{
backgroundColor: node.meta?.color
? `${node.meta.color}20`
: 'var(--bg-tertiary, #e9ecef)',
color: node.meta?.color ?? 'var(--text-secondary, #666)',
}}
>
{getIcon(node.category)}
</div>
<div className={styles.nodeItemInfo}>
<span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span>
<span className={styles.nodeItemDesc}>{getLabel(node.description, language)}</span>
</div>
</div>
);

View file

@ -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<string>;
expandedIoMethods: Set<string>;
onToggleCategory: (id: string) => void;
onToggleIoMethod: (method: string) => void;
}
export const NodeSidebar: React.FC<NodeSidebarProps> = ({
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<string, NodeType[]> = {};
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<string, NodeType[]> = {};
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<string>();
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<string, string> | undefined, lang?: string) =>
getLabel(t, lang ?? language);
return (
<div className={styles.sidebar}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3>
<input
type="text"
className={styles.sidebarSearch}
placeholder="Nodes durchsuchen..."
value={filter}
onChange={(e) => onFilterChange(e.target.value)}
/>
</div>
<div className={styles.nodeList}>
{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 (
<React.Fragment key={catId}>
{ioSubGroups.map(({ method, nodes }) => {
const methodLabel = getIoMethodLabel(method, language);
const isMethodExpanded = expandedIoMethods.has(method);
return (
<div key={`io-${method}`} className={styles.categoryGroup}>
<button
type="button"
className={styles.categoryHeader}
onClick={() => onToggleIoMethod(method)}
>
{isMethodExpanded ? (
<FaChevronDown className={styles.categoryIcon} />
) : (
<FaChevronRight className={styles.categoryIcon} />
)}
<span className={styles.categoryLabel}>{methodLabel}</span>
<span className={styles.categoryCount}>{nodes.length}</span>
</button>
{isMethodExpanded &&
nodes.map((node) => (
<NodeListItem
key={node.id}
node={node}
language={language}
getLabel={getLabelFn}
/>
))}
</div>
);
})}
</React.Fragment>
);
}
const items = groupedByCategory[catId] || [];
return (
<div key={catId} className={styles.categoryGroup}>
<button
type="button"
className={styles.categoryHeader}
onClick={() => onToggleCategory(catId)}
>
{isExpanded ? (
<FaChevronDown className={styles.categoryIcon} />
) : (
<FaChevronRight className={styles.categoryIcon} />
)}
<span className={styles.categoryLabel}>{label}</span>
<span className={styles.categoryCount}>{items.length}</span>
</button>
{isExpanded &&
items.map((node) => (
<NodeListItem
key={node.id}
node={node}
language={language}
getLabel={getLabelFn}
/>
))}
</div>
);
})}
</div>
</div>
);
};

View file

@ -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<string, React.ReactNode> = {
trigger: <FaPlay />,
input: <FaUser />,
flow: <FaCodeBranch />,
data: <FaDatabase />,
io: <FaPlug />,
human: <FaUser />,
};
export const DEFAULT_CATEGORY_ICON = <FaPlug />;

View file

@ -0,0 +1,27 @@
/**
* Approval node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const ApprovalNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<>
<div>
<label>Titel</label>
<input
value={(params.title as string) ?? ''}
onChange={(e) => updateParam('title', e.target.value)}
placeholder="Genehmigungstitel"
/>
</div>
<div>
<label>Beschreibung</label>
<textarea
value={(params.description as string) ?? ''}
onChange={(e) => updateParam('description', e.target.value)}
placeholder="Was genehmigt werden soll"
/>
</div>
</>
);

View file

@ -0,0 +1,29 @@
/**
* Comment node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const CommentNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<>
<div>
<label>Platzhalter</label>
<input
value={(params.placeholder as string) ?? ''}
onChange={(e) => updateParam('placeholder', e.target.value)}
placeholder="Kommentar eingeben..."
/>
</div>
<div>
<label>
<input
type="checkbox"
checked={(params.required as boolean) ?? true}
onChange={(e) => updateParam('required', e.target.checked)}
/>
Pflichtfeld
</label>
</div>
</>
);

View file

@ -0,0 +1,33 @@
/**
* Confirmation node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const ConfirmationNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<>
<div>
<label>Frage</label>
<input
value={(params.question as string) ?? ''}
onChange={(e) => updateParam('question', e.target.value)}
placeholder="Möchten Sie bestätigen?"
/>
</div>
<div>
<label>Bestätigen-Button</label>
<input
value={(params.confirmLabel as string) ?? 'Confirm'}
onChange={(e) => updateParam('confirmLabel', e.target.value)}
/>
</div>
<div>
<label>Ablehnen-Button</label>
<input
value={(params.rejectLabel as string) ?? 'Reject'}
onChange={(e) => updateParam('rejectLabel', e.target.value)}
/>
</div>
</>
);

View file

@ -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<NodeConfigRendererProps> = ({ 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 (
<div>
<label>Felder</label>
<div className={styles.formFieldsList}>
{fields.map((f, i) => (
<div
key={i}
className={styles.formFieldRow}
onDragOver={(e) => {
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);
}}
>
<div className={styles.formFieldRowHeader}>
<span
className={styles.formFieldDragHandle}
title="Zum Verschieben ziehen"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', String(i));
e.dataTransfer.effectAllowed = 'move';
}}
>
<FaGripVertical />
</span>
<div className={styles.formFieldInputs}>
<input
placeholder="name"
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], name: e.target.value };
updateParam('fields', next);
}}
/>
<input
placeholder="label"
value={f.label ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], label: e.target.value };
updateParam('fields', next);
}}
/>
</div>
</div>
<div className={styles.formFieldRowFooter}>
<select
value={f.type ?? 'string'}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], type: e.target.value };
updateParam('fields', next);
}}
style={{ width: 'auto', minWidth: 90 }}
>
<option value="string">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">Checkbox</option>
</select>
<label className={styles.formFieldRequiredLabel}>
<input
type="checkbox"
checked={f.required ?? false}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], required: e.target.checked };
updateParam('fields', next);
}}
/>
Pflichtfeld
</label>
<button
type="button"
onClick={() => removeField(i)}
title="Feld entfernen"
className={styles.formFieldRemoveButton}
>
<FaTimes />
</button>
</div>
</div>
))}
<button
type="button"
onClick={() =>
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
}
>
+ Feld
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,17 @@
/**
* Review node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const ReviewNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<div>
<label>Content-Referenz</label>
<input
value={(params.contentRef as string) ?? ''}
onChange={(e) => updateParam('contentRef', e.target.value)}
placeholder="{{nodeId.field}}"
/>
</div>
);

View file

@ -0,0 +1,50 @@
/**
* Selection node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const SelectionNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const options = (params.options as Array<{ value?: string; label?: string }>) ?? [];
return (
<div>
<label>Optionen</label>
{options.map((o, i) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<input
placeholder="value"
value={o.value ?? ''}
onChange={(e) => {
const next = [...options];
next[i] = { ...next[i], value: e.target.value };
updateParam('options', next);
}}
/>
<input
placeholder="label"
value={o.label ?? ''}
onChange={(e) => {
const next = [...options];
next[i] = { ...next[i], label: e.target.value };
updateParam('options', next);
}}
/>
</div>
))}
<button type="button" onClick={() => updateParam('options', [...options, { value: '', label: '' }])}>
+ Option
</button>
<div>
<label>
<input
type="checkbox"
checked={(params.multiple as boolean) ?? false}
onChange={(e) => updateParam('multiple', e.target.checked)}
/>
Mehrfachauswahl
</label>
</div>
</div>
);
};

View file

@ -0,0 +1,37 @@
/**
* Upload node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const UploadNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<>
<div>
<label>Accept (MIME)</label>
<input
value={(params.accept as string) ?? ''}
onChange={(e) => updateParam('accept', e.target.value)}
placeholder=".pdf,image/*"
/>
</div>
<div>
<label>Max Größe (MB)</label>
<input
type="number"
value={(params.maxSize as number) ?? 10}
onChange={(e) => updateParam('maxSize', parseFloat(e.target.value) || 0)}
/>
</div>
<div>
<label>
<input
type="checkbox"
checked={(params.multiple as boolean) ?? false}
onChange={(e) => updateParam('multiple', e.target.checked)}
/>
Mehrere Dateien
</label>
</div>
</>
);

View file

@ -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<NodeConfigRendererProps>;
export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
'input.form': FormNodeConfig,
'input.approval': ApprovalNodeConfig,
'input.upload': UploadNodeConfig,
'input.comment': CommentNodeConfig,
'input.review': ReviewNodeConfig,
'input.selection': SelectionNodeConfig,
'input.confirmation': ConfirmationNodeConfig,
};

View file

@ -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<string, unknown>;
updateParam: (key: string, value: unknown) => void;
}

View file

@ -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<string, Record<string, string>> = {
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;

View file

@ -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<string, { inputs: number; outputs: number }>();
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,
};
}),
};
}

View file

@ -1 +1,9 @@
export { Automation2FlowEditor } from './Automation2FlowEditor'; 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';

View file

@ -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<string, string> | undefined,
lang = 'de'
): string {
if (!text) return '';
if (typeof text === 'string') return text;
const rec = text as Record<string, string>;
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<string, string> | undefined, lang?: string) => string;

View file

@ -113,6 +113,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'feature.automation': <FaCogs />, 'feature.automation': <FaCogs />,
'feature.automation2': <FaProjectDiagram />, 'feature.automation2': <FaProjectDiagram />,
'page.feature.automation2.editor': <FaProjectDiagram />, 'page.feature.automation2.editor': <FaProjectDiagram />,
'page.feature.automation2.workflows': <FaProjectDiagram />,
'page.feature.automation2.workflows-tasks': <FaClipboardList />, 'page.feature.automation2.workflows-tasks': <FaClipboardList />,
'page.feature.chatbot.conversations': <FaComments />, 'page.feature.chatbot.conversations': <FaComments />,
'feature.chatbot': <FaComments />, 'feature.chatbot': <FaComments />,

View file

@ -33,6 +33,7 @@ import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView
// Automation2 Views // Automation2 Views
import { Automation2Page } from './views/automation2/Automation2Page'; import { Automation2Page } from './views/automation2/Automation2Page';
import { Automation2WorkflowsPage } from './views/automation2/Automation2WorkflowsPage';
import { Automation2WorkflowsTasksPage } from './views/automation2/Automation2WorkflowsTasksPage'; import { Automation2WorkflowsTasksPage } from './views/automation2/Automation2WorkflowsTasksPage';
// Workspace Views // Workspace Views
@ -135,6 +136,7 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
}, },
automation2: { automation2: {
editor: Automation2Page, editor: Automation2Page,
workflows: Automation2WorkflowsPage,
'workflows-tasks': Automation2WorkflowsTasksPage, 'workflows-tasks': Automation2WorkflowsTasksPage,
}, },
workspace: { workspace: {

View file

@ -4,6 +4,7 @@
* n8n-style flow builder with backend-driven node list. * n8n-style flow builder with backend-driven node list.
*/ */
import React from 'react'; import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { Automation2FlowEditor } from '../../../components/Automation2FlowEditor'; import { Automation2FlowEditor } from '../../../components/Automation2FlowEditor';
@ -11,6 +12,8 @@ import styles from '../../FeatureView.module.css';
export const Automation2Page: React.FC = () => { export const Automation2Page: React.FC = () => {
const instanceId = useInstanceId(); const instanceId = useInstanceId();
const [searchParams] = useSearchParams();
const workflowId = searchParams.get('workflowId');
const { currentLanguage } = useLanguage(); const { currentLanguage } = useLanguage();
const language = (currentLanguage?.slice(0, 2) || 'de') as string; const language = (currentLanguage?.slice(0, 2) || 'de') as string;
@ -25,7 +28,11 @@ export const Automation2Page: React.FC = () => {
return ( return (
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}> <div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
<Automation2FlowEditor instanceId={instanceId} language={language} /> <Automation2FlowEditor
instanceId={instanceId}
language={language}
initialWorkflowId={workflowId}
/>
</div> </div>
); );
}; };

View file

@ -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<Automation2Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [executingId, setExecutingId] = useState<string | null>(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<boolean> => {
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 ? (
<span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}> Ja</span>
) : (
<span style={{ color: 'var(--text-secondary, #666)' }}>Nein</span>
),
},
{
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 (
<div className={styles.adminPage}>
<p>Keine Feature-Instanz gefunden.</p>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Gespeicherte Workflows</h1>
<p className={styles.pageSubtitle}>
Workflows verwalten, ausführen und bearbeiten
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => load()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
</div>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable<Automation2Workflow>
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: <FaPlay />,
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."
/>
</div>
</div>
);
};

View file

@ -1,6 +1,6 @@
.container { .container {
padding: 1.5rem; padding: 1.5rem;
max-width: 800px; max-width: 900px;
} }
.container h2 { .container h2 {
@ -8,6 +8,77 @@
font-size: 1.25rem; 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 { .loading {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -1,40 +1,67 @@
/** /**
* Automation2WorkflowsTasksPage * Automation2WorkflowsTasksPage
* Workflows (collapsible) with runs and tasks. Complete tasks by type (form, approval, upload, etc.) * Tasks only (no workflow grouping).
* Form tasks open in Popup for fill-in and submit. * 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 React, { useState, useEffect, useCallback } from 'react';
import { FaChevronDown, FaChevronRight, FaSpinner } from 'react-icons/fa'; import { FaChevronDown, FaChevronRight, FaSpinner } from 'react-icons/fa';
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi'; import { useApiRequest } from '../../../hooks/useApi';
import { import {
fetchWorkflows,
fetchTasks, fetchTasks,
completeTask, completeTask,
type Automation2Workflow,
type Automation2Task, type Automation2Task,
} from '../../../api/automation2Api'; } from '../../../api/automation2Api';
import { Popup } from '../../../components/UiComponents/Popup'; import { Popup } from '../../../components/UiComponents/Popup';
import styles from './Automation2WorkflowsTasks.module.css'; import styles from './Automation2WorkflowsTasks.module.css';
const NODE_TYPE_LABELS: Record<string, string> = {
'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, unknown>): 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<string, string>)) {
return (label as Record<string, string>).de ?? (label as Record<string, string>).en ?? '';
}
return '';
}
export const Automation2WorkflowsTasksPage: React.FC = () => { export const Automation2WorkflowsTasksPage: React.FC = () => {
const instanceId = useInstanceId(); const instanceId = useInstanceId();
const { request } = useApiRequest(); const { request } = useApiRequest();
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [tasks, setTasks] = useState<Automation2Task[]>([]); const [tasks, setTasks] = useState<Automation2Task[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [expandedWorkflows, setExpandedWorkflows] = useState<Set<string>>(new Set()); const [completedExpanded, setCompletedExpanded] = useState(false);
const [submitting, setSubmitting] = useState<string | null>(null); const [submitting, setSubmitting] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!instanceId) return; if (!instanceId) return;
setLoading(true); setLoading(true);
try { try {
const [wfList, taskList] = await Promise.all([ const taskList = await fetchTasks(request, instanceId);
fetchWorkflows(request, instanceId),
fetchTasks(request, instanceId, { status: 'pending' }),
]);
setWorkflows(wfList);
setTasks(taskList); setTasks(taskList);
} catch (e) { } catch (e) {
console.error('[Automation2] load failed', e); console.error('[Automation2] load failed', e);
@ -47,15 +74,6 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
load(); load();
}, [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<string, unknown>) => { const handleComplete = async (taskId: string, result: Record<string, unknown>) => {
if (!instanceId) return; if (!instanceId) return;
setSubmitting(taskId); setSubmitting(taskId);
@ -69,19 +87,13 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
} }
}; };
const tasksByWorkflow = tasks.reduce<Record<string, Automation2Task[]>>((acc, t) => { const openTasks = tasks.filter((t) => t.status === 'pending');
const w = t.workflowId; const completedTasks = tasks.filter((t) => t.status !== 'pending');
if (!acc[w]) acc[w] = [];
acc[w].push(t);
return acc;
}, {});
const workflowLabel = (wf: Automation2Workflow) => wf.label || wf.id;
if (!instanceId) { if (!instanceId) {
return ( return (
<div className={styles.placeholder}> <div className={styles.placeholder}>
<h2>Workflows & Tasks</h2> <h2>Tasks</h2>
<p>Keine Feature-Instanz gefunden.</p> <p>Keine Feature-Instanz gefunden.</p>
</div> </div>
); );
@ -91,52 +103,68 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
return ( return (
<div className={styles.loading}> <div className={styles.loading}>
<FaSpinner className={styles.spinner} /> <FaSpinner className={styles.spinner} />
<p>Lade Workflows und Tasks</p> <p>Lade Tasks</p>
</div> </div>
); );
} }
return ( return (
<div className={styles.container}> <div className={styles.container}>
<h2>Workflows & Tasks</h2> <h2>Tasks</h2>
<div className={styles.workflowList}>
{workflows.map((wf) => { {/* Open tasks */}
const isExpanded = expandedWorkflows.has(wf.id); <section className={styles.section}>
const wfTasks = tasksByWorkflow[wf.id] ?? []; <h3 className={styles.sectionTitle}>
return ( Offene Tasks
<div key={wf.id} className={styles.workflowItem}> {openTasks.length > 0 && <span className={styles.badge}>{openTasks.length}</span>}
<button </h3>
type="button" {openTasks.length === 0 ? (
className={styles.workflowHeader}
onClick={() => toggleWorkflow(wf.id)}
>
{isExpanded ? <FaChevronDown /> : <FaChevronRight />}
<span>{workflowLabel(wf)}</span>
{wfTasks.length > 0 && <span className={styles.badge}>{wfTasks.length}</span>}
</button>
{isExpanded && (
<div className={styles.taskList}>
{wfTasks.length === 0 ? (
<p className={styles.empty}>Keine offenen Tasks</p> <p className={styles.empty}>Keine offenen Tasks</p>
) : ( ) : (
wfTasks.map((task) => ( <div className={styles.taskList}>
{openTasks.map((task) => (
<TaskCard <TaskCard
key={task.id} key={task.id}
task={task} task={task}
onSubmit={(result) => handleComplete(task.id, result)} onSubmit={(result) => handleComplete(task.id, result)}
submitting={submitting === task.id} submitting={submitting === task.id}
/> />
))}
</div>
)}
</section>
{/* Completed tasks */}
<section className={styles.section}>
<button
type="button"
className={styles.completedHeader}
onClick={() => setCompletedExpanded((p) => !p)}
>
{completedExpanded ? <FaChevronDown /> : <FaChevronRight />}
<span>Erledigte Tasks</span>
{completedTasks.length > 0 && (
<span className={styles.badge}>{completedTasks.length}</span>
)}
</button>
{completedExpanded && (
<div className={styles.completedList}>
{completedTasks.length === 0 ? (
<p className={styles.empty}>Keine erledigten Tasks</p>
) : (
completedTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onSubmit={(result) => handleComplete(task.id, result)}
submitting={submitting === task.id}
readOnly
/>
)) ))
)} )}
</div> </div>
)} )}
</div> </section>
);
})}
</div>
{workflows.length === 0 && (
<p className={styles.empty}>Keine Workflows. Erstelle einen im Editor und speichere ihn.</p>
)}
</div> </div>
); );
}; };
@ -145,18 +173,28 @@ interface TaskCardProps {
task: Automation2Task; task: Automation2Task;
onSubmit: (result: Record<string, unknown>) => void; onSubmit: (result: Record<string, unknown>) => void;
submitting: boolean; submitting: boolean;
readOnly?: boolean;
} }
const TaskCard: React.FC<TaskCardProps> = ({ task, onSubmit, submitting }) => { const TaskCard: React.FC<TaskCardProps> = ({
task,
onSubmit,
submitting,
readOnly = false,
}) => {
const [formData, setFormData] = useState<Record<string, unknown>>({}); const [formData, setFormData] = useState<Record<string, unknown>>({});
const [formPopupOpen, setFormPopupOpen] = useState(false); const [formPopupOpen, setFormPopupOpen] = useState(false);
const config = task.config ?? {}; const config = task.config ?? {};
const nodeType = task.nodeType; const nodeType = task.nodeType;
const stepLabel = getNodeStepLabel(config);
const renderInput = () => { const renderInput = () => {
if (readOnly) return null;
switch (nodeType) { switch (nodeType) {
case 'input.form': { 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 requiredFields = fields.filter((f) => f.required);
const allRequiredFilled = requiredFields.every((f) => { const allRequiredFilled = requiredFields.every((f) => {
const v = formData[f.name]; const v = formData[f.name];
@ -167,18 +205,27 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onSubmit, submitting }) => {
<div className={styles.formFields}> <div className={styles.formFields}>
{fields.map((f) => ( {fields.map((f) => (
<div key={f.name}> <div key={f.name}>
<label>{f.label || f.name}{f.required && ' *'}</label> <label>
{f.label || f.name}
{f.required && ' *'}
</label>
{f.type === 'boolean' ? ( {f.type === 'boolean' ? (
<input <input
type="checkbox" type="checkbox"
checked={(formData[f.name] as boolean) ?? false} checked={(formData[f.name] as boolean) ?? false}
onChange={(e) => setFormData((p) => ({ ...p, [f.name]: e.target.checked }))} onChange={(e) =>
setFormData((p) => ({ ...p, [f.name]: e.target.checked }))
}
/> />
) : ( ) : (
<input <input
type={f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'} type={
f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'
}
value={(formData[f.name] as string) ?? ''} value={(formData[f.name] as string) ?? ''}
onChange={(e) => setFormData((p) => ({ ...p, [f.name]: e.target.value }))} onChange={(e) =>
setFormData((p) => ({ ...p, [f.name]: e.target.value }))
}
/> />
)} )}
</div> </div>
@ -222,13 +269,21 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onSubmit, submitting }) => {
case 'input.approval': case 'input.approval':
return ( return (
<div> <div>
<h4>{config.title}</h4> {config.title && <h4>{config.title as string}</h4>}
{config.description && <p>{config.description}</p>} {config.description && <p>{config.description as string}</p>}
<div className={styles.approvalButtons}> <div className={styles.approvalButtons}>
<button type="button" onClick={() => onSubmit({ approved: true })} disabled={submitting}> <button
type="button"
onClick={() => onSubmit({ approved: true })}
disabled={submitting}
>
Genehmigen Genehmigen
</button> </button>
<button type="button" onClick={() => onSubmit({ approved: false })} disabled={submitting}> <button
type="button"
onClick={() => onSubmit({ approved: false })}
disabled={submitting}
>
Ablehnen Ablehnen
</button> </button>
</div> </div>
@ -245,14 +300,18 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onSubmit, submitting }) => {
<button <button
type="button" type="button"
onClick={() => onSubmit(formData)} onClick={() => onSubmit(formData)}
disabled={submitting || ((config.required !== false) && !formData.comment)} disabled={
submitting ||
((config.required !== false) && !formData.comment)
}
> >
Absenden Absenden
</button> </button>
</div> </div>
); );
case 'input.selection': { 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; const multiple = config.multiple as boolean;
return ( return (
<div> <div>
@ -265,7 +324,9 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onSubmit, submitting }) => {
onChange={(e) => { onChange={(e) => {
if (multiple) { if (multiple) {
const prev = (formData.selected as string[]) ?? []; 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 })); setFormData((p) => ({ ...p, selected: next }));
} else { } else {
setFormData((p) => ({ ...p, selected: o.value })); setFormData((p) => ({ ...p, selected: o.value }));
@ -311,7 +372,11 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onSubmit, submitting }) => {
return ( return (
<div> <div>
<p>Upload-Komponente noch nicht implementiert</p> <p>Upload-Komponente noch nicht implementiert</p>
<button type="button" onClick={() => onSubmit({ uploaded: [] })} disabled={submitting}> <button
type="button"
onClick={() => onSubmit({ uploaded: [] })}
disabled={submitting}
>
Platzhalter absenden Platzhalter absenden
</button> </button>
</div> </div>
@ -325,7 +390,11 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onSubmit, submitting }) => {
value={(formData.feedback as string) ?? ''} value={(formData.feedback as string) ?? ''}
onChange={(e) => setFormData({ feedback: e.target.value })} onChange={(e) => setFormData({ feedback: e.target.value })}
/> />
<button type="button" onClick={() => onSubmit(formData)} disabled={submitting}> <button
type="button"
onClick={() => onSubmit(formData)}
disabled={submitting}
>
Absenden Absenden
</button> </button>
</div> </div>
@ -334,7 +403,11 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onSubmit, submitting }) => {
return ( return (
<div> <div>
<p>Unbekannter Task-Typ: {nodeType}</p> <p>Unbekannter Task-Typ: {nodeType}</p>
<button type="button" onClick={() => onSubmit({})} disabled={submitting}> <button
type="button"
onClick={() => onSubmit({})}
disabled={submitting}
>
Absenden Absenden
</button> </button>
</div> </div>
@ -342,19 +415,46 @@ const TaskCard: React.FC<TaskCardProps> = ({ task, onSubmit, submitting }) => {
} }
}; };
const nodeTypeLabel: Record<string, string> = {
'input.form': 'Formular',
'input.approval': 'Genehmigung',
'input.upload': 'Upload',
'input.comment': 'Kommentar',
'input.review': 'Prüfung',
'input.selection': 'Auswahl',
'input.confirmation': 'Bestätigung',
};
return ( return (
<div className={styles.taskCard}> <div className={styles.taskCard}>
<div className={styles.taskType}>{nodeTypeLabel[nodeType] ?? nodeType}</div> <div className={styles.taskMeta}>
<div className={styles.taskMetaRow}>
<span className={styles.metaLabel}>Workflow</span>
<span className={styles.metaValue}>
{task.workflowLabel || task.workflowId || '—'}
</span>
</div>
<div className={styles.taskMetaRow}>
<span className={styles.metaLabel}>Erstellt</span>
<span className={styles.metaValue}>
{formatTimestamp(task.createdAt)}
</span>
</div>
<div className={styles.taskMetaRow}>
<span className={styles.metaLabel}>Fällig</span>
<span className={styles.metaValue}>
{formatTimestamp(task.dueAt)}
</span>
</div>
{stepLabel && (
<div className={styles.taskMetaRow}>
<span className={styles.metaLabel}>Schritt</span>
<span className={styles.metaValue}>{stepLabel}</span>
</div>
)}
<div className={styles.taskMetaRow}>
<span className={styles.metaLabel}>Typ</span>
<span className={styles.metaValue}>
{NODE_TYPE_LABELS[nodeType] ?? nodeType}
</span>
</div>
{task.nodeId && (
<div className={styles.taskMetaRow}>
<span className={styles.metaLabel}>Node</span>
<span className={styles.metaValueMono}>{task.nodeId}</span>
</div>
)}
</div>
{renderInput()} {renderInput()}
</div> </div>
); );

View file

@ -267,7 +267,8 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
icon: 'sitemap', icon: 'sitemap',
views: [ views: [
{ code: 'editor', label: { de: 'Editor', en: 'Editor' }, path: 'editor' }, { 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: { neutralization: {