grapheditor editor and template handling

This commit is contained in:
ValueOn AG 2026-04-07 14:22:52 +02:00
parent 3bf79e1ae5
commit 3a6f90a497
12 changed files with 911 additions and 130 deletions

View file

@ -20,7 +20,7 @@
display: flex;
flex-direction: column;
background: var(--bg-secondary, #f8f9fa);
border-right: 1px solid var(--border-color, #e0e0e0);
border-left: 1px solid var(--border-color, #e0e0e0);
overflow: hidden;
}

View file

@ -20,6 +20,8 @@ import {
publishVersion,
unpublishVersion,
archiveVersion,
createTemplateFromWorkflow,
copyTemplate,
type NodeType,
type NodeTypeCategory,
type Automation2Graph,
@ -27,12 +29,14 @@ import {
type ExecuteGraphResponse,
type WorkflowEntryPoint,
type AutoVersion,
type AutoTemplateScope,
} from '../../../api/workflowApi';
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader';
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
import { TemplatePicker } from './TemplatePicker';
import { getCategoryIcon } from '../nodes/shared/utils';
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
import {
@ -43,6 +47,7 @@ import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
import { usePrompt } from '../../../hooks/usePrompt';
import { EditorChatPanel } from './EditorChatPanel';
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
import { RunTracingPanel } from './RunTracingPanel';
import styles from './Automation2FlowEditor.module.css';
@ -56,12 +61,20 @@ interface Automation2FlowEditorProps {
language?: string;
/** When set, load this workflow on mount (e.g. from workflows list edit) */
initialWorkflowId?: string | null;
pendingFiles?: PendingFile[];
onRemovePendingFile?: (fileId: string) => void;
dataSources?: EditorDataSource[];
featureDataSources?: EditorFeatureDataSource[];
}
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
instanceId,
language = 'de',
initialWorkflowId,
pendingFiles,
onRemovePendingFile,
dataSources,
featureDataSources,
}) => {
const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt();
@ -71,7 +84,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup'])
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
);
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
@ -289,8 +302,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
const loadWorkflows = useCallback(async () => {
if (!instanceId) return;
try {
const items = await fetchWorkflows(request, instanceId);
setWorkflows(items);
const result = await fetchWorkflows(request, instanceId);
setWorkflows(Array.isArray(result) ? result : result.items);
} catch (e) {
console.error(`${LOG} loadWorkflows failed`, e);
}
@ -452,6 +465,42 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
}
}, [request, instanceId, currentWorkflowId, loadVersions]);
// Template: save current workflow as template
const [templateSaving, setTemplateSaving] = useState(false);
const handleSaveAsTemplate = useCallback(
async (scope: AutoTemplateScope) => {
if (!instanceId || !currentWorkflowId) return;
setTemplateSaving(true);
try {
await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope);
setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse);
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
} finally {
setTemplateSaving(false);
}
},
[request, instanceId, currentWorkflowId]
);
// Template: new workflow from template
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const handleNewFromTemplate = useCallback(
async (templateId: string) => {
if (!instanceId) return;
try {
const wf = await copyTemplate(request, instanceId, templateId);
setWorkflows((prev) => [...prev, wf]);
setCurrentWorkflowId(wf.id);
if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations);
setTemplatePickerOpen(false);
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
}
},
[request, instanceId, handleFromApiGraph]
);
const renderSidebar = () => {
if (loading) {
return (
@ -497,14 +546,54 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
const configurableSelected =
selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.'].some((p) =>
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) =>
selectedNode.type.startsWith(p)
);
return (
<div className={styles.container}>
{renderSidebar()}
{/* Chat/Tracing panel - left side */}
{(chatPanelOpen || tracingRunId) && (
<div style={{ width: 340, borderRight: '1px solid var(--border-color, #e0e0e0)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<button
onClick={() => { setChatPanelOpen(true); setTracingRunId(null); }}
style={{ flex: 1, padding: '8px', border: 'none', background: chatPanelOpen ? 'var(--bg-secondary, #f5f5f5)' : 'transparent', cursor: 'pointer', fontSize: '12px', fontWeight: 600 }}
>
Chat
</button>
<button
onClick={() => { setChatPanelOpen(false); setTracingRunId(tracingRunId || 'select'); }}
style={{ flex: 1, padding: '8px', border: 'none', background: !chatPanelOpen && tracingRunId ? 'var(--bg-secondary, #f5f5f5)' : 'transparent', cursor: 'pointer', fontSize: '12px', fontWeight: 600 }}
>
Tracing
</button>
<button
onClick={() => { setChatPanelOpen(false); setTracingRunId(null); }}
style={{ padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '14px' }}
>
×
</button>
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
{chatPanelOpen && currentWorkflowId ? (
<EditorChatPanel
instanceId={instanceId}
workflowId={currentWorkflowId}
onGraphUpdated={() => handleLoad(currentWorkflowId)}
pendingFiles={pendingFiles}
onRemovePendingFile={onRemovePendingFile}
dataSources={dataSources}
featureDataSources={featureDataSources}
/>
) : tracingRunId ? (
<RunTracingPanel instanceId={instanceId} runId={tracingRunId === 'select' ? null : tracingRunId} />
) : null}
</div>
</div>
)}
{/* Canvas area - center */}
<div className={styles.canvas}>
<CanvasHeader
workflows={workflows}
@ -527,6 +616,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
onArchiveVersion={handleArchiveVersion}
onCreateDraft={handleCreateDraft}
versionLoading={versionLoading}
onSaveAsTemplate={handleSaveAsTemplate}
templateSaving={templateSaving}
onNewFromTemplate={() => setTemplatePickerOpen(true)}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
@ -565,37 +657,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
)}
</div>
</div>
{(chatPanelOpen || tracingRunId) && (
<div style={{ width: 340, borderLeft: '1px solid var(--border-color, #e0e0e0)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<button
onClick={() => { setChatPanelOpen(true); setTracingRunId(null); }}
style={{ flex: 1, padding: '8px', border: 'none', background: chatPanelOpen ? 'var(--bg-secondary, #f5f5f5)' : 'transparent', cursor: 'pointer', fontSize: '12px', fontWeight: 600 }}
>
Chat
</button>
<button
onClick={() => { setChatPanelOpen(false); setTracingRunId(tracingRunId || 'select'); }}
style={{ flex: 1, padding: '8px', border: 'none', background: !chatPanelOpen && tracingRunId ? 'var(--bg-secondary, #f5f5f5)' : 'transparent', cursor: 'pointer', fontSize: '12px', fontWeight: 600 }}
>
Tracing
</button>
<button
onClick={() => { setChatPanelOpen(false); setTracingRunId(null); }}
style={{ padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '14px' }}
>
×
</button>
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
{chatPanelOpen && currentWorkflowId ? (
<EditorChatPanel instanceId={instanceId} workflowId={currentWorkflowId} />
) : tracingRunId ? (
<RunTracingPanel instanceId={instanceId} runId={tracingRunId === 'select' ? null : tracingRunId} />
) : null}
</div>
</div>
)}
{/* Node sidebar - right side */}
{renderSidebar()}
<PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}
@ -603,6 +667,13 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
invocations={invocations}
onApply={handleApplyWorkflowConfiguration}
/>
<TemplatePicker
open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)}
onSelect={handleNewFromTemplate}
instanceId={instanceId}
request={request}
/>
</div>
);
};

View file

@ -2,9 +2,9 @@
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result.
*/
import React from 'react';
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive } from 'react-icons/fa';
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion } from '../../../api/workflowApi';
import React, { useState, useRef, useEffect } from 'react';
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaRobot, FaBookmark, FaCaretDown } from 'react-icons/fa';
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
interface CanvasHeaderProps {
@ -28,6 +28,9 @@ interface CanvasHeaderProps {
onArchiveVersion?: (versionId: string) => void;
onCreateDraft?: () => void;
versionLoading?: boolean;
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
templateSaving?: boolean;
onNewFromTemplate?: () => void;
}
const STATUS_BADGE: Record<string, { label: string; color: string }> = {
@ -57,11 +60,31 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
onArchiveVersion,
onCreateDraft,
versionLoading,
onSaveAsTemplate,
templateSaving,
onNewFromTemplate,
}) => {
const currentVersion = versions?.find((v) => v.id === currentVersionId);
const currentStatus = currentVersion?.status || 'draft';
const badge = STATUS_BADGE[currentStatus] || STATUS_BADGE.draft;
const [newMenuOpen, setNewMenuOpen] = useState(false);
const newMenuRef = useRef<HTMLDivElement>(null);
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const templateMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const _handleClickOutside = (e: MouseEvent) => {
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
};
document.addEventListener('mousedown', _handleClickOutside);
return () => document.removeEventListener('mousedown', _handleClickOutside);
}, []);
const SCOPE_LABELS: Record<string, string> = { user: 'Meine Vorlagen', instance: 'Instanz', mandate: 'Mandant' };
return (
<div className={styles.canvasHeader}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
@ -79,9 +102,45 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
<FaCog />
</button>
)}
<button type="button" className={styles.retryButton} onClick={onNew}>
Neu
</button>
{/* Split "Neu" button */}
<div ref={newMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
<div style={{ display: 'flex' }}>
<button type="button" className={styles.retryButton} onClick={onNew} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}>
Neu
</button>
<button
type="button"
className={styles.retryButton}
onClick={() => setNewMenuOpen((p) => !p)}
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, paddingLeft: 4, paddingRight: 6, borderLeft: '1px solid rgba(0,0,0,0.15)' }}
title="Neu aus Vorlage"
>
<FaCaretDown style={{ fontSize: '0.7rem' }} />
</button>
</div>
{newMenuOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
<button
type="button"
onClick={() => { onNew(); setNewMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem' }}
>
Leerer Workflow
</button>
{onNewFromTemplate && (
<button
type="button"
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: '1px solid var(--border-color, #e0e0e0)' }}
>
Aus Vorlage...
</button>
)}
</div>
)}
</div>
<button
type="button"
className={styles.retryButton}
@ -90,6 +149,35 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
>
{saving ? <FaSpinner className={styles.spinner} /> : 'Speichern'}
</button>
{/* Save as template */}
{currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
<button
type="button"
className={styles.retryButton}
onClick={() => setTemplateMenuOpen((p) => !p)}
disabled={templateSaving}
title="Als Vorlage speichern"
>
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />Als Vorlage</>}
</button>
{templateMenuOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
{(['user', 'instance', 'mandate'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: s !== 'user' ? '1px solid var(--border-color, #e0e0e0)' : undefined }}
>
{SCOPE_LABELS[s]}
</button>
))}
</div>
)}
</div>
)}
<select
value={currentWorkflowId ?? ''}
onChange={(e) => {
@ -124,8 +212,9 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
)}
</button>
{onToggleChat && (
<button type="button" className={styles.retryButton} onClick={onToggleChat} title="AI Chat öffnen">
Chat
<button type="button" className={styles.retryButton} onClick={onToggleChat} title="AI Chat & Tracing öffnen">
<FaRobot style={{ marginRight: '0.4rem' }} />
AI Chat
</button>
)}
</div>

View file

@ -2,18 +2,46 @@
* EditorChatPanel
*
* AI Chat sidebar for the GraphicalEditor.
* Uses shared ChatMessageList and ChatInput components.
* Streams responses via SSE (same pattern as Workspace chat).
* File & data-source attachment UX mirrors WorkspaceInput:
* - Files: drag & drop from FolderTree onto input area, or click in UDB
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
*/
import React, { useState, useCallback, useRef } from 'react';
import { startSseStream } from '../../../utils/sseClient';
import { ChatMessageList, ChatInput } from '../../Chat';
import { ChatMessageList } from '../../Chat';
import type { ChatMessage } from '../../Chat';
import { getPageIcon } from '../../../config/pageRegistry';
export interface PendingFile {
fileId: string;
fileName: string;
itemType?: 'file' | 'folder';
}
export interface EditorDataSource {
id: string;
label: string;
path?: string;
sourceType?: string;
}
export interface EditorFeatureDataSource {
id: string;
featureInstanceId: string;
featureCode: string;
tableName: string;
label: string;
}
interface EditorChatPanelProps {
instanceId: string;
workflowId: string | null;
onGraphUpdated?: () => void;
pendingFiles?: PendingFile[];
onRemovePendingFile?: (fileId: string) => void;
dataSources?: EditorDataSource[];
featureDataSources?: EditorFeatureDataSource[];
}
let _msgCounter = 0;
@ -22,62 +50,77 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
instanceId,
workflowId,
onGraphUpdated,
pendingFiles = [],
onRemovePendingFile,
dataSources = [],
featureDataSources = [],
}) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(false);
const [prompt, setPrompt] = useState('');
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
const [showSourcePicker, setShowSourcePicker] = useState(false);
const [treeDropOver, setTreeDropOver] = useState(false);
const abortRef = useRef<(() => void) | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const _handleSend = useCallback((text: string) => {
if (!workflowId || loading) return;
const _toggleDataSource = useCallback((dsId: string) => {
setAttachedDataSourceIds(prev =>
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
);
}, []);
const _toggleFeatureDataSource = useCallback((fdsId: string) => {
setAttachedFeatureDataSourceIds(prev =>
prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId],
);
}, []);
const _handleSend = useCallback(() => {
const trimmed = prompt.trim();
if (!workflowId || loading || !trimmed) return;
const fileIds = pendingFiles.map(f => f.fileId);
const body: Record<string, unknown> = {
message: trimmed,
conversationHistory: messages.map(m => ({ role: m.role, message: m.content })),
userLanguage: navigator.language?.slice(0, 2) || 'de',
};
if (fileIds.length > 0) body.fileIds = fileIds;
if (attachedDataSourceIds.length > 0) body.dataSourceIds = attachedDataSourceIds;
if (attachedFeatureDataSourceIds.length > 0) body.featureDataSourceIds = attachedFeatureDataSourceIds;
const userMsg: ChatMessage = {
id: `user-${++_msgCounter}`,
role: 'user',
content: text,
content: trimmed,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, userMsg]);
setMessages(prev => [...prev, userMsg]);
setPrompt('');
setShowSourcePicker(false);
setLoading(true);
const assistantId = `asst-${++_msgCounter}`;
let accumulated = '';
setMessages((prev) => [
...prev,
{ id: assistantId, role: 'assistant', content: '', timestamp: Date.now() },
]);
const conversationHistory = messages.map((m) => ({
role: m.role,
message: m.content,
}));
setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]);
const cleanup = startSseStream({
url: `/api/workflows/${instanceId}/${workflowId}/chat/stream`,
body: {
message: text,
conversationHistory,
userLanguage: navigator.language?.slice(0, 2) || 'de',
},
body,
handlers: {
onChunk: (event) => {
if (event.content) {
accumulated += event.content;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: accumulated } : m
)
);
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: accumulated } : m));
}
},
onRawEvent: (event) => {
if (event.type === 'message' && event.content) {
accumulated += event.content;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: accumulated } : m
)
);
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: accumulated } : m));
}
if (event.type === 'toolResult' || event.type === 'toolCall') {
onGraphUpdated?.();
@ -85,11 +128,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
},
onComplete: () => {
if (!accumulated) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: 'Done.' } : m
)
);
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: 'Done.' } : m));
}
onGraphUpdated?.();
setLoading(false);
@ -97,35 +136,51 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
onError: (event) => {
const errText = event.content || 'Request failed';
if (!accumulated) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: `Error: ${errText}` } : m
)
);
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `Error: ${errText}` } : m));
}
setLoading(false);
},
onStopped: () => {
setLoading(false);
},
onStopped: () => setLoading(false),
},
onConnectionError: (err) => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: `Error: ${err.message}` }
: m
)
);
setLoading(false);
},
onStreamEnd: () => {
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `Error: ${err.message}` } : m));
setLoading(false);
},
onStreamEnd: () => setLoading(false),
});
abortRef.current = cleanup;
}, [loading, workflowId, instanceId, messages, onGraphUpdated]);
}, [prompt, loading, workflowId, instanceId, messages, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds]);
const _handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
_handleSend();
}
}, [_handleSend]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('application/tree-items')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setTreeDropOver(true);
}
}, []);
const _handleDragLeave = useCallback(() => setTreeDropOver(false), []);
const _handleDrop = useCallback((e: React.DragEvent) => {
setTreeDropOver(false);
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) {
e.preventDefault();
e.stopPropagation();
}
}, []);
const hasAttachments = pendingFiles.length > 0 || attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0;
const sourceCount = attachedDataSourceIds.length + attachedFeatureDataSourceIds.length;
const hasSourceOptions = dataSources.length > 0 || featureDataSources.length > 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
@ -134,12 +189,219 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
isProcessing={loading}
emptyMessage="Describe what you want to build. The AI will create and modify nodes on the canvas."
/>
<ChatInput
onSend={_handleSend}
isProcessing={loading}
disabled={!workflowId}
placeholder={workflowId ? 'Describe a change...' : 'Save workflow first'}
/>
{/* Pending files (from UDB drag/click) */}
{pendingFiles.length > 0 && (
<div style={{
padding: '6px 12px', display: 'flex', gap: 4, flexWrap: 'wrap',
borderTop: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #fafafa)',
}}>
{pendingFiles.map(pf => (
<span key={pf.fileId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
fontWeight: 500, border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
}}>
{pf.itemType === 'folder' ? '\uD83D\uDCC1' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
{onRemovePendingFile && (
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,
}}>x</button>
)}
</span>
))}
</div>
)}
{/* Attached data sources chips */}
{(attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0) && (
<div style={{
padding: '6px 12px', display: 'flex', gap: 4, flexWrap: 'wrap',
borderTop: pendingFiles.length > 0 ? 'none' : '1px solid var(--border-color, #e0e0e0)',
background: '#fafafa',
}}>
{attachedDataSourceIds.map(dsId => {
const ds = dataSources.find(d => d.id === dsId);
return (
<span key={dsId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
}}>
\uD83D\uDD17 {ds?.label || dsId}
<button onClick={() => _toggleDataSource(dsId)} style={{
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1,
}}>x</button>
</span>
);
})}
{attachedFeatureDataSourceIds.map(fdsId => {
const fds = featureDataSources.find(d => d.id === fdsId);
const fdsIcon = fds ? getPageIcon(`feature.${fds.featureCode}`) : null;
return (
<span key={fdsId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500,
}}>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 11 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span>
{fds?.label || fdsId}
<button onClick={() => _toggleFeatureDataSource(fdsId)} style={{
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1,
}}>x</button>
</span>
);
})}
</div>
)}
{/* Input area */}
<div
style={{
borderTop: hasAttachments ? 'none' : '1px solid var(--border-color, #e0e0e0)',
padding: '8px 12px',
display: 'flex', gap: 6, alignItems: 'flex-end',
outline: treeDropOver ? '2px dashed var(--primary-color, #F25843)' : 'none',
background: treeDropOver ? 'rgba(242, 88, 67, 0.08)' : undefined,
transition: 'background 0.15s, outline 0.15s',
}}
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}
onDrop={_handleDrop}
>
<textarea
ref={textareaRef}
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={_handleKeyDown}
placeholder={workflowId ? 'Describe a change...' : 'Save workflow first'}
disabled={!workflowId || loading}
style={{
flex: 1, minHeight: 36, maxHeight: 100, resize: 'vertical',
padding: '8px 10px', borderRadius: 8,
border: '1px solid var(--border-color, #ccc)',
fontSize: 13, fontFamily: 'inherit', outline: 'none',
}}
rows={1}
/>
{/* Source picker button */}
{hasSourceOptions && (
<div style={{ position: 'relative' }} ref={pickerRef}>
<button
onClick={() => setShowSourcePicker(prev => !prev)}
disabled={loading || !workflowId}
title="Datenquellen anhängen"
style={{
width: 36, height: 36, borderRadius: 8,
border: '1px solid var(--border-color, #ddd)',
background: sourceCount > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: sourceCount > 0 ? '#2e7d32' : '#666',
cursor: loading || !workflowId ? 'not-allowed' : 'pointer',
fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: loading ? 0.5 : 1, position: 'relative',
}}
>
{'\uD83D\uDD17'}
{sourceCount > 0 && (
<span style={{
position: 'absolute', top: -4, right: -4,
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{sourceCount}</span>
)}
</button>
{showSourcePicker && (
<div style={{
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 220, maxHeight: 260, overflowY: 'auto',
}}>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>
Active Sources auswählen
</div>
{dataSources.map(ds => {
const isSelected = attachedDataSourceIds.includes(ds.id);
return (
<div key={ds.id} onClick={() => _toggleDataSource(ds.id)} style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: isSelected ? '#e8f5e9' : 'transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isSelected ? '#e8f5e9' : ''; }}
>
<span style={{
width: 14, height: 14, borderRadius: 3,
border: isSelected ? '2px solid #2e7d32' : '2px solid #ccc',
background: isSelected ? '#2e7d32' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0,
}}>{isSelected ? '\u2713' : ''}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ds.label || ds.path || ds.id}
</span>
</div>
);
})}
{featureDataSources.length > 0 && (
<>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
Feature Data Sources
</div>
{featureDataSources.map(fds => {
const isSelected = attachedFeatureDataSourceIds.includes(fds.id);
return (
<div key={fds.id} onClick={() => _toggleFeatureDataSource(fds.id)} style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: isSelected ? '#f3e5f5' : 'transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isSelected ? '#f3e5f5' : ''; }}
>
<span style={{
width: 14, height: 14, borderRadius: 3,
border: isSelected ? '2px solid #7b1fa2' : '2px solid #ccc',
background: isSelected ? '#7b1fa2' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0,
}}>{isSelected ? '\u2713' : ''}</span>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12, color: '#7b1fa2', flexShrink: 0 }}>
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fds.label || fds.featureCode} {fds.tableName}
</span>
</div>
);
})}
</>
)}
</div>
)}
</div>
)}
{loading ? (
<button onClick={() => abortRef.current?.()} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: 12,
}}>Stop</button>
) : (
<button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',
background: prompt.trim() && workflowId ? 'var(--primary-color, #F25843)' : '#ccc',
color: '#fff', cursor: prompt.trim() && workflowId ? 'pointer' : 'default',
fontWeight: 600, fontSize: 12,
}}>Send</button>
)}
</div>
</div>
);
};

View file

@ -22,7 +22,7 @@ interface NodeConfigPanelProps {
request?: ApiRequestFunction;
}
const CONFIGURABLE_PREFIXES = ['trigger.', 'input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'flow.', 'file.'];
const CONFIGURABLE_PREFIXES = ['trigger.', 'input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'flow.', 'file.', 'trustee.'];
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
node,

View file

@ -0,0 +1,150 @@
/**
* TemplatePicker - modal to browse and select a workflow template for creating a new workflow.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { FaSpinner } from 'react-icons/fa';
import {
fetchTemplates,
type AutoWorkflowTemplate,
type AutoTemplateScope,
type ApiRequestFunction,
} from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
const SCOPE_LABELS: Record<AutoTemplateScope | 'all', string> = {
all: 'Alle',
user: 'Meine',
instance: 'Instanz',
mandate: 'Mandant',
system: 'System',
};
interface TemplatePickerProps {
open: boolean;
onClose: () => void;
onSelect: (templateId: string) => void;
instanceId: string;
request: ApiRequestFunction;
}
export const TemplatePicker: React.FC<TemplatePickerProps> = ({
open,
onClose,
onSelect,
instanceId,
request,
}) => {
const [templates, setTemplates] = useState<AutoWorkflowTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [activeScope, setActiveScope] = useState<AutoTemplateScope | 'all'>('all');
const [copying, setCopying] = useState<string | null>(null);
const _load = useCallback(async () => {
if (!instanceId || !open) return;
setLoading(true);
try {
const scope = activeScope === 'all' ? undefined : activeScope;
const result = await fetchTemplates(request, instanceId, scope);
setTemplates(Array.isArray(result) ? result : result.items);
} catch {
setTemplates([]);
} finally {
setLoading(false);
}
}, [instanceId, request, open, activeScope]);
useEffect(() => {
_load();
}, [_load]);
const _handleSelect = useCallback(
async (templateId: string) => {
setCopying(templateId);
try {
await onSelect(templateId);
} finally {
setCopying(null);
}
},
[onSelect]
);
if (!open) return null;
return (
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="tpl-picker-title">
<div className={styles.workflowModal} style={{ maxWidth: 600, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}>
<h3 id="tpl-picker-title" className={styles.workflowModalTitle}>
Neu aus Vorlage
</h3>
<p className={styles.workflowModalHint}>
Wählen Sie eine Vorlage, um einen neuen Workflow zu erstellen.
</p>
<div style={{ display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
{(['all', 'user', 'instance', 'mandate', 'system'] as const).map((s) => (
<button
key={s}
type="button"
className={activeScope === s ? styles.workflowModalBtnPrimary : styles.workflowModalBtnSecondary}
onClick={() => setActiveScope(s)}
style={{ fontSize: '0.8rem', padding: '4px 10px' }}
>
{SCOPE_LABELS[s]}
</button>
))}
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 120 }}>
{loading ? (
<div style={{ textAlign: 'center', padding: 24 }}>
<FaSpinner className={styles.spinner} />
</div>
) : templates.length === 0 ? (
<div style={{ textAlign: 'center', padding: 24, color: 'var(--text-secondary, #888)' }}>
Keine Vorlagen gefunden.
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color, #e0e0e0)', textAlign: 'left' }}>
<th style={{ padding: '6px 8px' }}>Name</th>
<th style={{ padding: '6px 8px', width: 80 }}>Scope</th>
<th style={{ padding: '6px 8px', width: 100 }}></th>
</tr>
</thead>
<tbody>
{templates.map((tpl) => (
<tr key={tpl.id} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
<td style={{ padding: '8px' }}>{tpl.label}</td>
<td style={{ padding: '8px', fontSize: '0.8rem', color: 'var(--text-secondary, #888)' }}>
{SCOPE_LABELS[(tpl.templateScope as AutoTemplateScope) || 'user']}
</td>
<td style={{ padding: '8px', textAlign: 'right' }}>
<button
type="button"
className={styles.workflowModalBtnPrimary}
style={{ fontSize: '0.8rem', padding: '4px 10px' }}
onClick={() => _handleSelect(tpl.id)}
disabled={copying !== null}
>
{copying === tpl.id ? <FaSpinner className={styles.spinner} /> : 'Übernehmen'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.workflowModalActions} style={{ marginTop: 12 }}>
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
Abbrechen
</button>
</div>
</div>
</div>
);
};

View file

@ -1,4 +1,5 @@
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
export { FlowCanvas } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
export { NodeConfigPanel } from './editor/NodeConfigPanel';

View file

@ -0,0 +1,92 @@
/**
* Trustee node config featureInstanceId, optional SharePoint connection + folder, prompt.
* Covers: trustee.extractFromFiles, trustee.processDocuments, trustee.syncToAccounting.
*/
import React, { useEffect, useState } from 'react';
import type { NodeConfigRendererProps } from './types';
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
export const TrusteeNodeConfig: React.FC<NodeConfigRendererProps> = ({
params,
updateParam,
instanceId,
request,
nodeType = 'trustee.extractFromFiles',
}) => {
const [connections, setConnections] = useState<UserConnection[]>([]);
const [loading, setLoading] = useState(false);
const isExtract = nodeType === 'trustee.extractFromFiles';
useEffect(() => {
if (isExtract && instanceId && request) {
setLoading(true);
fetchConnections(request, instanceId)
.then(setConnections)
.catch(() => setConnections([]))
.finally(() => setLoading(false));
}
}, [isExtract, instanceId, request]);
return (
<>
<div>
<label>Trustee Instance ID</label>
<input
value={(params.featureInstanceId as string) ?? ''}
onChange={(e) => updateParam('featureInstanceId', e.target.value)}
placeholder="Trustee Feature-Instanz-ID"
/>
</div>
{isExtract && (
<>
<div>
<label>SharePoint Connection (optional)</label>
<select
value={(params.connectionId as string) ?? ''}
onChange={(e) => updateParam('connectionId', e.target.value)}
disabled={loading}
>
<option value="">{loading ? 'Laden...' : 'Keine (Dateien aus vorherigem Schritt)'}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
</option>
))}
</select>
</div>
<div>
<label>SharePoint Ordnerpfad (optional)</label>
<input
value={(params.sharepointFolder as string) ?? ''}
onChange={(e) => updateParam('sharepointFolder', e.target.value)}
placeholder="/sites/MySite/Documents/Expenses"
/>
</div>
<div>
<label>AI Prompt (optional)</label>
<textarea
value={(params.prompt as string) ?? ''}
onChange={(e) => updateParam('prompt', e.target.value)}
placeholder="Zusätzliche Anweisungen für die AI-Extraktion"
rows={3}
/>
</div>
</>
)}
{!isExtract && (
<div>
<label>Document List (Referenz)</label>
<input
value={(params.documentList as string) ?? ''}
onChange={(e) => updateParam('documentList', e.target.value)}
placeholder="Referenz auf vorherigen Schritt (automatisch verknüpft)"
/>
</div>
)}
</>
);
};

View file

@ -22,6 +22,7 @@ import { LoopNodeConfig } from '../loop/LoopNodeConfig';
import { FormStartNodeConfig } from '../start/FormStartNodeConfig';
import { ScheduleStartNodeConfig } from '../start/ScheduleStartNodeConfig';
import { FileCreateNodeConfig } from './FileCreateNodeConfig';
import { TrusteeNodeConfig } from './TrusteeNodeConfig';
export type NodeConfigComponent = ComponentType<NodeConfigRendererProps>;
@ -62,4 +63,7 @@ export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
'flow.ifElse': IfElseNodeConfig,
'flow.switch': SwitchNodeConfig,
'flow.loop': LoopNodeConfig,
'trustee.extractFromFiles': TrusteeNodeConfig,
'trustee.processDocuments': TrusteeNodeConfig,
'trustee.syncToAccounting': TrusteeNodeConfig,
};

View file

@ -19,7 +19,7 @@ interface FileEntry {
interface FilesTabProps {
context: UdbContext;
onFileSelect?: (fileId: string) => void;
onFileSelect?: (fileId: string, fileName?: string) => void;
}
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
@ -297,7 +297,10 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
onFileSelect={onFileSelect}
onFileSelect={onFileSelect ? (fileId: string) => {
const file = files.find(f => f.id === fileId);
onFileSelect(fileId, file?.fileName);
} : undefined}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_refreshAll}

View file

@ -24,7 +24,7 @@ interface UnifiedDataBarProps {
onRenameChat?: (chatId: string, newName: string) => void;
onDeleteChat?: (chatId: string) => void;
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
onFileSelect?: (fileId: string) => void;
onFileSelect?: (fileId: string, fileName?: string) => void;
onSourcesChanged?: () => void;
className?: string;
}

View file

@ -1,15 +1,24 @@
/**
* GraphicalEditorPage
*
* n8n-style flow builder with backend-driven node list and UDB sidebar.
* Layout: [UDB sidebar (collapsible)] [FlowEditor (flex)] [Chat/Tracing (inside FlowEditor)]
* UDB provides access to Files & Sources while configuring nodes.
* AI Chat and Tracing panels are managed by the FlowEditor's CanvasHeader.
*
* File/Source attachment UX mirrors the Workspace:
* - Files: click in UDB FilesTab added as pendingFile chip in chat input
* - Data Sources: 🔗 picker button in chat input (loaded from UDB API)
*/
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { FaDatabase, FaChevronLeft } from 'react-icons/fa';
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { Automation2FlowEditor as FlowEditor } from '../../../components/FlowEditor';
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from '../../../components/FlowEditor';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import api from '../../../api';
import styles from '../../FeatureView.module.css';
interface GraphicalEditorPageProps {
@ -26,16 +35,76 @@ export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
const instanceId = persistentInstanceId || urlInstanceId;
const mandateId = persistentMandateId || urlMandateId;
const [searchParams] = useSearchParams();
const workflowId = searchParams.get('workflowId');
const initialWorkflowIdRef = useRef(searchParams.get('workflowId'));
const { currentLanguage } = useLanguage();
const language = (currentLanguage?.slice(0, 2) || 'de') as string;
const [udbTab, setUdbTab] = useState<UdbTab>('files');
const [udbOpen, setUdbOpen] = useState(false);
const [udbOpen, setUdbOpen] = useState(true);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [dataSources, setDataSources] = useState<EditorDataSource[]>([]);
const [featureDataSources, setFeatureDataSources] = useState<EditorFeatureDataSource[]>([]);
const udbContext: UdbContext = useMemo(() => ({
instanceId: instanceId || '',
mandateId: mandateId || '',
featureInstanceId: instanceId || '',
}), [mandateId, instanceId]);
}), [instanceId, mandateId]);
useEffect(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/datasources`)
.then(res => {
const list = (res.data.dataSources || res.data || []).map((d: any) => ({
id: d.id, label: d.label || d.path || d.id, path: d.path, sourceType: d.sourceType,
}));
setDataSources(list);
})
.catch(() => setDataSources([]));
}, [instanceId]);
useEffect(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/feature-datasources`)
.then(res => {
const list = (res.data.featureDataSources || res.data || []).map((d: any) => ({
id: d.id, featureInstanceId: d.featureInstanceId, featureCode: d.featureCode,
tableName: d.tableName, label: d.label || d.tableName,
}));
setFeatureDataSources(list);
})
.catch(() => setFeatureDataSources([]));
}, [instanceId]);
const _handleFileSelect = useCallback((fileId: string, fileName?: string) => {
setPendingFiles(prev => {
if (prev.some(f => f.fileId === fileId)) return prev;
return [...prev, { fileId, fileName: fileName || fileId.slice(0, 12) }];
});
}, []);
const _handleRemovePendingFile = useCallback((fileId: string) => {
setPendingFiles(prev => prev.filter(f => f.fileId !== fileId));
}, []);
const _handleSourcesChanged = useCallback(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/datasources`)
.then(res => {
setDataSources((res.data.dataSources || res.data || []).map((d: any) => ({
id: d.id, label: d.label || d.path || d.id, path: d.path, sourceType: d.sourceType,
})));
})
.catch(() => {});
api.get(`/api/workspace/${instanceId}/feature-datasources`)
.then(res => {
setFeatureDataSources((res.data.featureDataSources || res.data || []).map((d: any) => ({
id: d.id, featureInstanceId: d.featureInstanceId, featureCode: d.featureCode,
tableName: d.tableName, label: d.label || d.tableName,
})));
})
.catch(() => {});
}, [instanceId]);
if (!instanceId) {
return (
@ -47,33 +116,73 @@ export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
}
return (
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
{udbOpen && (
<div style={{ width: 280, borderRight: '1px solid var(--border-color, #e0e0e0)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<span style={{ fontWeight: 600, fontSize: '13px' }}>Daten</span>
<button onClick={() => setUdbOpen(false)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '16px' }}>×</button>
<div style={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative' }}>
{/* UDB Sidebar */}
{udbOpen ? (
<div style={{
width: 280, minWidth: 280,
borderRight: '1px solid var(--border-color, #e0e0e0)',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
background: 'var(--bg-primary, #fff)',
}}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '6px 12px',
borderBottom: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #f8f9fa)',
}}>
<span style={{ fontWeight: 600, fontSize: '13px', display: 'flex', alignItems: 'center', gap: 6 }}>
<FaDatabase style={{ fontSize: 12, opacity: 0.6 }} /> Daten
</span>
<button
onClick={() => setUdbOpen(false)}
title="Sidebar schliessen"
style={{
border: 'none', background: 'transparent', cursor: 'pointer',
fontSize: '14px', padding: '2px 4px', borderRadius: 4,
display: 'flex', alignItems: 'center',
}}
>
<FaChevronLeft style={{ fontSize: 12 }} />
</button>
</div>
<UnifiedDataBar
context={udbContext}
activeTab={udbTab}
onTabChange={setUdbTab}
hideTabs={['chats']}
onFileSelect={_handleFileSelect}
onSourcesChanged={_handleSourcesChanged}
/>
</div>
) : (
<button
onClick={() => setUdbOpen(true)}
title="Daten-Sidebar öffnen"
style={{
position: 'absolute', left: 0, top: '50%', transform: 'translateY(-50%)',
zIndex: 20, width: 28, height: 80,
border: '1px solid var(--border-color, #ddd)', borderLeft: 'none',
borderRadius: '0 8px 8px 0',
background: 'var(--bg-primary, #fff)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '2px 0 6px rgba(0,0,0,0.06)',
}}
>
<FaDatabase style={{ fontSize: 13, opacity: 0.5 }} />
</button>
)}
{/* FlowEditor */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
{!udbOpen && (
<button
onClick={() => setUdbOpen(true)}
style={{ position: 'absolute', left: 8, top: 8, zIndex: 10, padding: '4px 10px', border: '1px solid var(--border-color, #ddd)', borderRadius: '6px', background: 'var(--bg-primary, #fff)', cursor: 'pointer', fontSize: '12px' }}
>
Daten
</button>
)}
<FlowEditor
instanceId={instanceId}
language={language}
initialWorkflowId={workflowId}
initialWorkflowId={initialWorkflowIdRef.current}
pendingFiles={pendingFiles}
onRemovePendingFile={_handleRemovePendingFile}
dataSources={dataSources}
featureDataSources={featureDataSources}
/>
</div>
</div>