integrated and initial teste unified automation

This commit is contained in:
ValueOn AG 2026-04-07 22:32:14 +02:00
parent 3a6f90a497
commit bc977b8281
28 changed files with 1287 additions and 734 deletions

View file

@ -14,13 +14,28 @@
SIDEBAR - Node List
============================================================================= */
.resizeDivider {
flex-shrink: 0;
width: 5px;
cursor: col-resize;
background: var(--border-color, #e0e0e0);
transition: background 0.15s;
position: relative;
z-index: 5;
}
.resizeDivider:hover,
.resizeDivider:active {
background: var(--primary-color, #007bff);
}
.sidebar {
flex-shrink: 0;
width: 280px;
display: flex;
flex-direction: column;
background: var(--bg-secondary, #f8f9fa);
border-left: 1px solid var(--border-color, #e0e0e0);
border-left: none;
overflow: hidden;
}
@ -108,6 +123,7 @@
cursor: grab;
transition: background 0.15s;
border: 1px solid transparent;
position: relative;
}
.nodeItem:hover {
@ -151,6 +167,29 @@
text-overflow: ellipsis;
}
.nodeItem .nodeItemTooltip {
display: none;
position: absolute;
left: 0;
top: 100%;
z-index: 100;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
color: var(--text-primary, #333);
white-space: normal;
word-break: break-word;
max-width: 280px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
pointer-events: none;
}
.nodeItem:hover .nodeItemTooltip {
display: block;
}
/* Loading / Error */
.loading,
.error {
@ -318,6 +357,19 @@
box-shadow: 0 0 0 2px var(--primary-color, #007bff);
}
.canvasNodeHighlighted {
transition: border-color 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
}
@keyframes pulseGlow {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.canvasNodeHighlighted[style*="box-shadow"] {
animation: pulseGlow 1.5s ease-in-out infinite;
}
.canvasNodeContent {
display: flex;
align-items: flex-start;
@ -360,6 +412,39 @@
text-decoration: underline;
}
.canvasNodeComment {
font-size: 0.7rem;
color: var(--text-tertiary, #999);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.canvasNode .canvasNodeCommentTooltip {
display: none;
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: calc(100% + 6px);
z-index: 100;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
padding: 0.4rem 0.6rem;
font-size: 0.75rem;
color: var(--text-primary, #333);
white-space: normal;
word-break: break-word;
max-width: 260px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
pointer-events: none;
}
.canvasNode:hover .canvasNodeCommentTooltip {
display: block;
}
.canvasNodeInput {
width: 100%;
padding: 0.15rem 0.25rem;
@ -446,6 +531,13 @@
color: var(--text-tertiary, #999);
}
.nodeConfigDescription {
margin: -0.5rem 0 0.75rem;
font-size: 0.75rem;
color: var(--text-secondary, #666);
line-height: 1.4;
}
.nodeConfigPanel label {
display: block;
font-size: 0.75rem;
@ -713,13 +805,13 @@
.scheduleModeBlock {
position: relative;
/* Ausgewählte Karte (orange) + Text auf „An“-Chips im erweiterten Bereich */
--schedule-active: var(--schedule-mode-active, var(--color-secondary, #f25843));
--schedule-active-border: var(--schedule-mode-active-border, var(--color-text, #3a3a3a));
--schedule-active: var(--schedule-mode-active, var(--color-secondary));
--schedule-active-border: var(--schedule-mode-active-border, var(--color-text));
display: flex;
flex-direction: column;
gap: 0;
border-radius: 25px;
border: 1px solid var(--color-text, #ddd);
border-radius: 8px;
border: 1px solid var(--color-border, #E2E8F0);
background-color: var(--bg-primary, #fff);
color: var(--color-text, #222);
overflow: hidden;
@ -1451,3 +1543,33 @@
border-color: var(--primary-color, #007bff);
color: var(--primary-color, #007bff);
}
/* Right panel tab bar (Nodes / Tracing) */
.rightTabBar {
display: flex;
border-bottom: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
background: var(--bg-primary, #fff);
}
.rightTab {
flex: 1;
padding: 8px;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary, #666);
transition: background 0.15s, color 0.15s;
}
.rightTab:hover {
background: var(--bg-hover, #f0f0f0);
}
.rightTabActive {
background: var(--bg-secondary, #f5f5f5);
color: var(--text-primary, #333);
box-shadow: inset 0 -2px 0 var(--primary-color, #007bff);
}

View file

@ -5,7 +5,7 @@
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync.
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { FaSpinner } from 'react-icons/fa';
import { useApiRequest } from '../../../hooks/useApi';
import {
@ -49,6 +49,8 @@ import { usePrompt } from '../../../hooks/usePrompt';
import { EditorChatPanel } from './EditorChatPanel';
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
import { RunTracingPanel } from './RunTracingPanel';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from './Automation2FlowEditor.module.css';
const LOG = '[Automation2]';
@ -58,6 +60,7 @@ const DEFAULT_INVOCATIONS = (): WorkflowEntryPoint[] =>
interface Automation2FlowEditorProps {
instanceId: string;
mandateId?: string;
language?: string;
/** When set, load this workflow on mount (e.g. from workflows list edit) */
initialWorkflowId?: string | null;
@ -65,16 +68,21 @@ interface Automation2FlowEditorProps {
onRemovePendingFile?: (fileId: string) => void;
dataSources?: EditorDataSource[];
featureDataSources?: EditorFeatureDataSource[];
onFileSelect?: (fileId: string, fileName?: string) => void;
onSourcesChanged?: () => void;
}
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
instanceId,
mandateId,
language = 'de',
initialWorkflowId,
pendingFiles,
onRemovePendingFile,
dataSources,
featureDataSources,
onFileSelect,
onSourcesChanged,
}) => {
const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt();
@ -96,12 +104,64 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
const [saving, setSaving] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(DEFAULT_INVOCATIONS);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [chatPanelOpen, setChatPanelOpen] = useState(false);
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes');
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
const udbContext: UdbContext = useMemo(() => ({
instanceId,
mandateId: mandateId || '',
featureInstanceId: instanceId,
}), [instanceId, mandateId]);
const [versions, setVersions] = useState<AutoVersion[]>([]);
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false);
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
});
const [sidebarWidth, setSidebarWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.sidebarWidth') ?? ''); return v >= 200 && v <= 500 ? v : 280; } catch { return 280; }
});
const resizingRef = useRef<{ target: 'left' | 'right'; startX: number; startW: number } | null>(null);
useEffect(() => {
const _onMouseMove = (e: MouseEvent) => {
if (!resizingRef.current) return;
const { target, startX, startW } = resizingRef.current;
const delta = e.clientX - startX;
if (target === 'left') {
setLeftPanelWidth(Math.max(240, Math.min(600, startW + delta)));
} else {
setSidebarWidth(Math.max(200, Math.min(500, startW - delta)));
}
};
const _onMouseUp = () => {
if (!resizingRef.current) return;
const { target } = resizingRef.current;
resizingRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
if (target === 'left') {
setLeftPanelWidth((w) => { try { localStorage.setItem('flowEditor.leftPanelWidth', String(w)); } catch {} return w; });
} else {
setSidebarWidth((w) => { try { localStorage.setItem('flowEditor.sidebarWidth', String(w)); } catch {} return w; });
}
};
document.addEventListener('mousemove', _onMouseMove);
document.addEventListener('mouseup', _onMouseUp);
return () => { document.removeEventListener('mousemove', _onMouseMove); document.removeEventListener('mouseup', _onMouseUp); };
}, []);
const _startResize = useCallback((target: 'left' | 'right', e: React.MouseEvent) => {
e.preventDefault();
resizingRef.current = { target, startX: e.clientX, startW: target === 'left' ? leftPanelWidth : sidebarWidth };
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [leftPanelWidth, sidebarWidth]);
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []);
const nodeOutputsPreview = useMemo(
@ -149,7 +209,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
...(ep ? { entryPointId: ep } : {}),
});
setExecuteResult(result);
if (result.runId) setTracingRunId(result.runId);
if (result.runId) {
setTracingRunId(result.runId);
setRightTab('tracing');
}
} catch (err: unknown) {
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
} finally {
@ -317,11 +380,13 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
loadWorkflows();
}, [loadWorkflows]);
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (initialWorkflowId && workflows.length > 0 && !currentWorkflowId && nodeTypes.length > 0) {
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
if (lastAppliedInitialRef.current === initialWorkflowId) return;
lastAppliedInitialRef.current = initialWorkflowId;
handleWorkflowSelect(initialWorkflowId);
}
}, [initialWorkflowId, workflows, currentWorkflowId, handleWorkflowSelect, nodeTypes.length]);
}, [initialWorkflowId, workflows, handleWorkflowSelect, nodeTypes.length]);
useEffect(() => {
if (loading || nodeTypes.length === 0) return;
@ -501,10 +566,21 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
[request, instanceId, handleFromApiGraph]
);
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
try {
await updateWorkflow(request, instanceId, workflowId, { label: newName });
setWorkflows((prev) => prev.map((w) => w.id === workflowId ? { ...w, label: newName } : w));
} catch (e: unknown) {
console.error(`${LOG} rename failed`, e);
}
}, [request, instanceId]);
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
const renderSidebar = () => {
if (loading) {
return (
<div className={styles.sidebar}>
<div className={styles.sidebar} style={_sidebarStyle}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3>
</div>
@ -517,7 +593,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
}
if (error) {
return (
<div className={styles.sidebar}>
<div className={styles.sidebar} style={_sidebarStyle}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3>
</div>
@ -540,6 +616,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
expandedCategories={expandedCategories}
onToggleCategory={toggleCategory}
excludedCategories={sidebarExcludedCategories}
style={_sidebarStyle}
/>
);
};
@ -552,46 +629,45 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
return (
<div className={styles.container}>
{/* 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)' }}>
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
{leftPanelOpen && (<>
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
<div className={styles.rightTabBar}>
{(['chats', 'files', 'sources'] as const).map((tab) => (
<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 }}
key={tab}
className={`${styles.rightTab} ${udbTab === tab ? styles.rightTabActive : ''}`}
onClick={() => setUdbTab(tab)}
>
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' }}
>
×
{{ chats: 'Chats', files: 'Dateien', sources: 'Quellen' }[tab]}
</button>
))}
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
{chatPanelOpen && currentWorkflowId ? (
{udbTab === 'chats' ? (
<EditorChatPanel
instanceId={instanceId}
workflowId={currentWorkflowId}
onGraphUpdated={() => handleLoad(currentWorkflowId)}
onGraphUpdated={() => { if (currentWorkflowId) handleLoad(currentWorkflowId); }}
pendingFiles={pendingFiles}
onRemovePendingFile={onRemovePendingFile}
dataSources={dataSources}
featureDataSources={featureDataSources}
/>
) : tracingRunId ? (
<RunTracingPanel instanceId={instanceId} runId={tracingRunId === 'select' ? null : tracingRunId} />
) : null}
</div>
</div>
) : (
<UnifiedDataBar
context={udbContext}
activeTab={udbTab}
onTabChange={setUdbTab}
hideTabs={['chats']}
onFileSelect={onFileSelect}
onSourcesChanged={onSourcesChanged}
/>
)}
</div>
</div>
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('left', e)} />
</>)}
{/* Canvas area - center */}
<div className={styles.canvas}>
@ -603,7 +679,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
onSave={handleSave}
onExecute={handleExecute}
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
onToggleChat={() => setChatPanelOpen((prev) => !prev)}
onToggleChat={() => setLeftPanelOpen((prev) => !prev)}
saving={saving}
executing={executing}
hasNodes={canvasNodes.length > 0}
@ -619,6 +695,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
onSaveAsTemplate={handleSaveAsTemplate}
templateSaving={templateSaving}
onNewFromTemplate={() => setTemplatePickerOpen(true)}
onWorkflowRename={handleWorkflowRename}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
@ -632,6 +709,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
getLabel={(node) => node.title ?? node.label ?? node.type}
getCategoryIcon={getCategoryIcon}
onSelectionChange={setSelectedNode}
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
/>
</div>
{configurableSelected && selectedNode && (
@ -658,8 +736,40 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
</div>
</div>
{/* Node sidebar - right side */}
{renderSidebar()}
{/* Right panel: Nodes + Tracing tabs */}
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} />
<div style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}>
<div className={styles.rightTabBar}>
<button
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
onClick={() => setRightTab('nodes')}
>
Nodes
</button>
<button
className={`${styles.rightTab} ${rightTab === 'tracing' ? styles.rightTabActive : ''}`}
onClick={() => { setRightTab('tracing'); if (!tracingRunId) setTracingRunId('select'); }}
>
Tracing
</button>
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
{rightTab === 'nodes' ? (
renderSidebar()
) : (
<RunTracingPanel
instanceId={instanceId}
runId={tracingRunId === 'select' ? null : tracingRunId}
onNodeSelect={(nodeId) => {
const node = canvasNodes.find((n) => n.id === nodeId);
if (node) setSelectedNode(node);
}}
onActiveStepsChange={setTracingNodeStatuses}
/>
)}
</div>
</div>
<PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}

View file

@ -2,8 +2,8 @@
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result.
*/
import React, { useState, useRef, useEffect } from 'react';
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaRobot, FaBookmark, FaCaretDown } from 'react-icons/fa';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown } from 'react-icons/fa';
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
@ -31,6 +31,7 @@ interface CanvasHeaderProps {
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
templateSaving?: boolean;
onNewFromTemplate?: () => void;
onWorkflowRename?: (workflowId: string, newName: string) => void;
}
const STATUS_BADGE: Record<string, { label: string; color: string }> = {
@ -63,6 +64,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
onSaveAsTemplate,
templateSaving,
onNewFromTemplate,
onWorkflowRename,
}) => {
const currentVersion = versions?.find((v) => v.id === currentVersionId);
const currentStatus = currentVersion?.status || 'draft';
@ -74,6 +76,34 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const templateMenuRef = useRef<HTMLDivElement>(null);
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState('');
const nameInputRef = useRef<HTMLInputElement>(null);
const currentWorkflow = workflows.find((w) => w.id === currentWorkflowId);
const _startNameEdit = useCallback(() => {
if (!currentWorkflowId || !onWorkflowRename) return;
setNameValue(currentWorkflow?.label || '');
setEditingName(true);
}, [currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
const _commitNameEdit = useCallback(() => {
setEditingName(false);
const trimmed = nameValue.trim();
if (!trimmed || !currentWorkflowId || !onWorkflowRename) return;
if (trimmed !== currentWorkflow?.label) {
onWorkflowRename(currentWorkflowId, trimmed);
}
}, [nameValue, currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
useEffect(() => {
if (editingName && nameInputRef.current) {
nameInputRef.current.focus();
nameInputRef.current.select();
}
}, [editingName]);
useEffect(() => {
const _handleClickOutside = (e: MouseEvent) => {
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
@ -87,10 +117,33 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
return (
<div className={styles.canvasHeader}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<h4 className={styles.canvasTitle} style={{ margin: 0 }}>
Workflow-Editor
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
{/* Workflow name: inline editable */}
{currentWorkflowId && currentWorkflow ? (
editingName ? (
<input
ref={nameInputRef}
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onBlur={_commitNameEdit}
onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }}
style={{ padding: '0.25rem 0.4rem', fontSize: '0.95rem', fontWeight: 600, border: '1px solid var(--primary-color, #007bff)', borderRadius: 4, outline: 'none', minWidth: 140, maxWidth: 300 }}
/>
) : (
<h4
className={styles.canvasTitle}
style={{ margin: 0, cursor: onWorkflowRename ? 'pointer' : 'default', fontSize: '0.95rem', fontWeight: 600 }}
onClick={_startNameEdit}
title={onWorkflowRename ? 'Klicken zum Umbenennen' : undefined}
>
{currentWorkflow.label}
</h4>
)
) : (
<h4 className={styles.canvasTitle} style={{ margin: 0, fontStyle: 'italic', opacity: 0.6 }}>
Neuer Workflow
</h4>
)}
{onWorkflowSettings && (
<button
type="button"
@ -212,9 +265,9 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
)}
</button>
{onToggleChat && (
<button type="button" className={styles.retryButton} onClick={onToggleChat} title="AI Chat & Tracing öffnen">
<FaRobot style={{ marginRight: '0.4rem' }} />
AI Chat
<button type="button" className={styles.retryButton} onClick={onToggleChat} title="Workspace-Panel (Chats, Dateien, Quellen)">
<FaDatabase style={{ marginRight: '0.4rem' }} />
Workspace
</button>
)}
</div>

View file

@ -44,8 +44,16 @@ interface FlowCanvasProps {
getLabel: (node: CanvasNode) => string;
getCategoryIcon: (category: string) => React.ReactNode;
onSelectionChange?: (node: CanvasNode | null) => void;
highlightedNodeIds?: Record<string, string>;
}
const HIGHLIGHT_COLORS: Record<string, string> = {
running: '#f0ad4e',
completed: '#28a745',
failed: '#dc3545',
skipped: '#6c757d',
};
export const FlowCanvas: React.FC<FlowCanvasProps> = ({
nodes,
connections,
@ -56,6 +64,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
getLabel,
getCategoryIcon,
onSelectionChange,
highlightedNodeIds,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
@ -621,18 +630,21 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
const isSelected = selectedNodeIds.has(node.id);
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
const displayTitle = node.title ?? node.label ?? getLabel(node);
const hlStatus = highlightedNodeIds?.[node.id];
const hlColor = hlStatus ? HIGHLIGHT_COLORS[hlStatus] : null;
return (
<div
key={node.id}
className={`${styles.canvasNode} ${isSelected ? styles.canvasNodeSelected : ''}`}
className={`${styles.canvasNode} ${isSelected ? styles.canvasNodeSelected : ''} ${hlStatus ? styles.canvasNodeHighlighted : ''}`}
style={{
left: node.x,
top: node.y,
width: NODE_WIDTH,
height: NODE_HEIGHT,
borderColor: color,
backgroundColor: `${color}15`,
borderColor: hlColor || color,
backgroundColor: hlColor ? `${hlColor}20` : `${color}15`,
boxShadow: hlStatus === 'running' ? `0 0 12px ${hlColor}80` : undefined,
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => {
@ -743,7 +755,13 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
{displayTitle}
</span>
)}
{node.comment && (
<span className={styles.canvasNodeComment}>{node.comment}</span>
)}
</div>
{node.comment && (
<div className={styles.canvasNodeCommentTooltip}>{node.comment}</div>
)}
</div>
</div>
);

View file

@ -107,6 +107,11 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
</div>
)}
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
{nodeType?.description && (
<p className={styles.nodeConfigDescription}>
{getLabel(nodeType.description, language)}
</p>
)}
<ConfigRenderer
params={params}
updateParam={updateParam}

View file

@ -21,7 +21,9 @@ export const NodeListItem: React.FC<NodeListItemProps> = ({
language,
getLabel,
getCategoryIcon: getIcon = getCategoryIcon,
}) => (
}) => {
const desc = getLabel(node.description, language);
return (
<div
className={styles.nodeItem}
draggable
@ -43,7 +45,9 @@ export const NodeListItem: React.FC<NodeListItemProps> = ({
</div>
<div className={styles.nodeItemInfo}>
<span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span>
<span className={styles.nodeItemDesc}>{getLabel(node.description, language)}</span>
<span className={styles.nodeItemDesc}>{desc}</span>
</div>
{desc && <div className={styles.nodeItemTooltip}>{desc}</div>}
</div>
);
};

View file

@ -21,6 +21,7 @@ interface NodeSidebarProps {
onToggleCategory: (id: string) => void;
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */
excludedCategories?: Set<string>;
style?: React.CSSProperties;
}
export const NodeSidebar: React.FC<NodeSidebarProps> = ({
@ -32,6 +33,7 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
expandedCategories,
onToggleCategory,
excludedCategories,
style,
}) => {
const filteredNodeTypes = useMemo(() => {
const visible = nodeTypes.filter(
@ -78,7 +80,7 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
getLabel(t, lang ?? language);
return (
<div className={styles.sidebar}>
<div className={styles.sidebar} style={style}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3>
<input

View file

@ -1,10 +1,11 @@
/**
* RunTracingPanel
*
* Shows AutoStepLog entries for a workflow run with live-update capability.
* Displays per-node status (running/completed/failed/skipped) with timing info.
* Shows AutoStepLog entries for a workflow run with live SSE push.
* Falls back to polling if SSE connection fails.
* Displays per-node status, timing, I/O snapshots, and retry info.
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useApiRequest } from '../../../hooks/useApi';
import type { AutoStepLog } from '../../../api/workflowApi';
@ -12,6 +13,7 @@ interface RunTracingPanelProps {
instanceId: string;
runId: string | null;
onNodeSelect?: (nodeId: string) => void;
onActiveStepsChange?: (nodeStatuses: Record<string, string>) => void;
}
const STATUS_COLORS: Record<string, string> = {
@ -30,13 +32,60 @@ const STATUS_ICONS: Record<string, string> = {
skipped: '—',
};
function _formatTimestamp(ts: number | string | null | undefined): string {
if (!ts) return '';
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts);
if (isNaN(d.getTime())) return '';
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function _truncateJson(obj: unknown, maxLen = 300): string {
if (!obj || (typeof obj === 'object' && Object.keys(obj as object).length === 0)) return '';
try {
const s = JSON.stringify(obj, null, 2);
return s.length > maxLen ? s.slice(0, maxLen) + '\n...' : s;
} catch {
return String(obj);
}
}
const CollapsibleSection: React.FC<{ label: string; content: string }> = ({ label, content }) => {
const [open, setOpen] = useState(false);
if (!content) return null;
return (
<div style={{ marginTop: '4px' }}>
<button
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
color: 'var(--text-link, #0969da)', fontSize: '11px', textDecoration: 'underline',
}}
>
{open ? '▾' : '▸'} {label}
</button>
{open && (
<pre style={{
margin: '4px 0 0', padding: '6px', borderRadius: '4px',
background: 'var(--bg-secondary, #f6f8fa)', fontSize: '11px',
whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: '200px', overflowY: 'auto',
}}>
{content}
</pre>
)}
</div>
);
};
export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
instanceId,
runId,
onNodeSelect,
onActiveStepsChange,
}) => {
const [steps, setSteps] = useState<AutoStepLog[]>([]);
const [loading, setLoading] = useState(false);
const [sseConnected, setSseConnected] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const { request } = useApiRequest();
const loadSteps = useCallback(async () => {
@ -55,11 +104,65 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
}
}, [runId, instanceId, request]);
// SSE live-push connection
useEffect(() => {
if (!runId || !instanceId) return;
loadSteps();
const url = `/api/workflows/${instanceId}/runs/${runId}/stream`;
const es = new EventSource(url);
eventSourceRef.current = es;
es.onopen = () => setSseConnected(true);
es.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'keepalive') return;
if (payload.type === 'run_complete' || payload.type === 'run_failed') {
loadSteps();
es.close();
setSseConnected(false);
return;
}
if (payload.status === 'running') {
setSteps((prev) => {
const exists = prev.some((s) => s.id === payload.id);
if (exists) return prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s);
return [...prev, payload as AutoStepLog];
});
} else {
setSteps((prev) => prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s));
}
} catch { /* ignore parse errors */ }
};
es.onerror = () => {
setSseConnected(false);
es.close();
};
return () => {
es.close();
eventSourceRef.current = null;
setSseConnected(false);
};
}, [runId, instanceId]); // eslint-disable-line react-hooks/exhaustive-deps
// Fallback polling when SSE is not connected
useEffect(() => {
if (sseConnected || !runId || !instanceId) return;
const interval = setInterval(loadSteps, 3000);
return () => clearInterval(interval);
}, [loadSteps]);
}, [sseConnected, runId, instanceId, loadSteps]);
// Emit active node statuses for canvas highlighting
useEffect(() => {
if (!onActiveStepsChange) return;
const nodeStatuses: Record<string, string> = {};
for (const step of steps) {
nodeStatuses[step.nodeId] = step.status;
}
onActiveStepsChange(nodeStatuses);
}, [steps, onActiveStepsChange]);
if (!runId) {
return (
@ -77,7 +180,14 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
{steps.length === 0 && !loading && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '13px' }}>No steps recorded yet.</div>
)}
{steps.map((step) => (
{steps.map((step: any) => {
const startStr = _formatTimestamp(step.startedAt);
const endStr = _formatTimestamp(step.completedAt);
const inputStr = _truncateJson(step.inputSnapshot);
const outputStr = _truncateJson(step.output);
const isLoop = step.inputSnapshot?._loopIndex != null;
return (
<div
key={step.id}
onClick={() => onNodeSelect?.(step.nodeId)}
@ -89,6 +199,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
background: 'var(--bg-primary, #fff)',
cursor: 'pointer',
fontSize: '13px',
marginLeft: isLoop ? '16px' : '0',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@ -98,19 +209,44 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
</span>
<strong>{step.nodeType}</strong>
<span style={{ color: '#888', marginLeft: '6px' }}>({step.nodeId})</span>
{isLoop && (
<span style={{ color: '#666', marginLeft: '6px', fontSize: '11px' }}>
[iter {step.inputSnapshot._loopIndex}]
</span>
)}
</span>
<span style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{step.retryCount > 0 && (
<span style={{ color: '#f0ad4e', fontSize: '11px' }} title="Retry count">
{step.retryCount}x retry
</span>
)}
{step.durationMs != null && (
<span style={{ color: '#888', fontSize: '12px' }}>{step.durationMs}ms</span>
)}
</span>
</div>
{(startStr || endStr) && (
<div style={{ color: '#888', fontSize: '11px', marginTop: '2px' }}>
{startStr && <span>{startStr}</span>}
{startStr && endStr && <span> </span>}
{endStr && <span>{endStr}</span>}
</div>
)}
{step.error && (
<div style={{ color: '#dc3545', fontSize: '12px', marginTop: '4px' }}>{step.error}</div>
)}
{step.tokensUsed > 0 && (
<div style={{ color: '#888', fontSize: '11px', marginTop: '2px' }}>{step.tokensUsed} tokens</div>
)}
<CollapsibleSection label="Input" content={inputStr} />
<CollapsibleSection label="Output" content={outputStr} />
</div>
))}
);
})}
</div>
);
};

View file

@ -3,54 +3,57 @@
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
padding: 5px;
border: none;
border-radius: 50%;
border-radius: 6px;
font-size: 12px;
font-family: var(--font-family);
cursor: pointer;
transition: all 0.2s ease;
transition: all 0.15s ease;
white-space: nowrap;
position: relative;
min-width: 28px;
min-height: 28px;
background: var(--color-secondary);
color: var(--color-bg);
min-width: 26px;
min-height: 26px;
background: var(--color-gray, #718096);
color: #fff;
}
.actionButton:hover {
background: var(--color-secondary-hover);
background: var(--color-gray-hover, #4A5568);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.actionButton:disabled {
opacity: 0.6;
opacity: 0.4;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
/* Disabled state class */
.actionButton.disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none !important;
background: #ccc !important;
color: #666 !important;
background: var(--color-gray-disabled, #CBD5E0) !important;
color: var(--color-text-secondary, #718096) !important;
}
.actionButton.disabled:hover {
background: #ccc !important;
background: var(--color-gray-disabled, #CBD5E0) !important;
transform: none !important;
box-shadow: none !important;
}
.actionButton:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.3);
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb, 74, 111, 165), 0.25);
}
.actionIcon {
font-size: 16px;
height: 16px;
width: 16px;
font-size: 14px;
height: 14px;
width: 14px;
display: flex;
align-items: center;
justify-content: center;
@ -58,7 +61,7 @@
/* Loading State */
.actionButton.loading {
opacity: 0.7;
opacity: 0.65;
cursor: not-allowed;
}
@ -66,7 +69,6 @@
animation: spin 1s linear infinite;
}
/* Delete button loading state - no animation for user-friendly experience */
.actionButton.delete.loading .actionIcon {
animation: none;
}
@ -82,8 +84,8 @@
gap: 2px;
justify-content: center;
align-items: center;
background: var(--color-secondary);
border-radius: 25px;
background: var(--color-gray, #718096);
border-radius: 6px;
padding: 2px;
}
@ -96,8 +98,8 @@
}
.confirmButton:hover {
background: rgba(255, 255, 255, 0.2) !important;
transform: scale(1.05);
background: rgba(255, 255, 255, 0.15) !important;
transform: none;
}
.cancelButton {
@ -109,121 +111,121 @@
}
.cancelButton:hover {
background: rgba(255, 255, 255, 0.2) !important;
transform: scale(1.05);
background: rgba(255, 255, 255, 0.15) !important;
transform: none;
}
/* Action Button Container */
.actionButtons {
display: flex;
gap: 2px;
gap: 4px;
justify-content: center;
align-items: center;
width: 100%;
margin: 0 auto;
}
/* Button Variants - All use same styling as delete button */
/* Button Variants */
.actionButton.edit {
background: var(--color-secondary);
background: var(--color-secondary, #4A6FA5);
color: white;
}
.actionButton.edit:hover {
background: var(--color-secondary-hover);
background: var(--color-secondary-hover, #3D5D8A);
}
.actionButton.delete {
background: var(--color-secondary);
background: var(--color-gray, #718096);
color: white;
}
.actionButton.delete:hover {
background: var(--color-secondary-hover);
background: var(--color-red, #C53030);
}
.actionButton.download {
background: var(--color-secondary);
background: var(--color-gray, #718096);
color: white;
}
.actionButton.download:hover {
background: var(--color-secondary-hover);
background: var(--color-gray-hover, #4A5568);
}
.actionButton.view {
background: var(--color-secondary);
background: var(--color-secondary, #4A6FA5);
color: white;
}
.actionButton.view:hover {
background: var(--color-secondary-hover);
background: var(--color-secondary-hover, #3D5D8A);
}
.actionButton.copy {
background: var(--color-secondary);
background: var(--color-gray, #718096);
color: white;
}
.actionButton.copy:hover {
background: var(--color-secondary-hover);
background: var(--color-gray-hover, #4A5568);
}
.actionButton.connect {
background: var(--color-secondary);
background: var(--color-secondary, #4A6FA5);
color: white;
}
.actionButton.connect:hover {
background: var(--color-secondary-hover);
background: var(--color-secondary-hover, #3D5D8A);
}
.actionButton.refresh {
background: var(--color-secondary);
background: var(--color-gray, #718096);
color: white;
}
.actionButton.refresh:hover {
background: var(--color-secondary-hover);
background: var(--color-gray-hover, #4A5568);
}
.actionButton.remove {
background: var(--color-secondary);
background: var(--color-gray, #718096);
color: white;
}
.actionButton.remove:hover {
background: var(--color-secondary-hover);
background: var(--color-red, #C53030);
}
/* Generic Custom Action Button */
.actionButton.custom {
background: var(--color-secondary);
background: var(--color-gray, #718096);
color: white;
}
.actionButton.custom:hover {
background: var(--color-secondary-hover);
background: var(--color-gray-hover, #4A5568);
}
/* Success State */
.actionButton.success {
background: #28a745 !important;
background: var(--color-success, #38A169) !important;
color: white !important;
}
.actionButton.success:hover {
background: #218838 !important;
background: var(--color-success-hover, #2F855A) !important;
}
/* Error State */
.actionButton.error {
background: #dc3545 !important;
background: var(--color-red, #C53030) !important;
color: white !important;
}
.actionButton.error:hover {
background: #c82333 !important;
background: #9B2C2C !important;
}
/* Responsive Design */
@ -234,20 +236,20 @@
}
.actionButton {
padding: 4px 8px;
padding: 4px;
font-size: 11px;
min-width: 24px;
min-height: 24px;
min-width: 22px;
min-height: 22px;
}
.actionIcon {
font-size: 14px;
height: 14px;
width: 14px;
font-size: 13px;
height: 13px;
width: 13px;
}
}
/* Dark theme support - All use same styling as delete button */
/* Dark theme support */
@media (prefers-color-scheme: dark) {
.actionButton.edit {
background: var(--color-secondary);
@ -258,19 +260,19 @@
}
.actionButton.delete {
background: var(--color-secondary);
background: var(--color-gray);
}
.actionButton.delete:hover {
background: var(--color-secondary-hover);
background: var(--color-red);
}
.actionButton.download {
background: var(--color-secondary);
background: var(--color-gray);
}
.actionButton.download:hover {
background: var(--color-secondary-hover);
background: var(--color-gray-hover);
}
.actionButton.view {
@ -282,11 +284,11 @@
}
.actionButton.copy {
background: var(--color-secondary);
background: var(--color-gray);
}
.actionButton.copy:hover {
background: var(--color-secondary-hover);
background: var(--color-gray-hover);
}
.actionButton.connect {
@ -298,18 +300,18 @@
}
.actionButton.refresh {
background: var(--color-secondary);
background: var(--color-gray);
}
.actionButton.refresh:hover {
background: var(--color-secondary-hover);
background: var(--color-gray-hover);
}
.actionButton.custom {
background: var(--color-secondary);
background: var(--color-gray);
}
.actionButton.custom:hover {
background: var(--color-secondary-hover);
background: var(--color-gray-hover);
}
}

View file

@ -15,8 +15,8 @@
gap: 20px;
padding: 15px;
background: var(--color-bg);
border: 1px solid var(--color-primary);
border-radius: 25px;
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 8px;
}
.searchContainer {
@ -44,8 +44,8 @@
gap: 5px;
height: 40px;
padding: 0 14px;
border: 1px solid var(--color-primary);
border-radius: 25px;
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font-size: 12px;
@ -58,9 +58,9 @@
}
.csvExportButton:hover:not(:disabled) {
background: var(--color-secondary);
color: var(--color-bg);
border-color: var(--color-secondary);
background: var(--color-gray, #718096);
color: #fff;
border-color: var(--color-gray, #718096);
}
.csvExportButton:disabled {
@ -80,21 +80,21 @@
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid var(--color-primary);
border-radius: 50%;
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
transition: all 0.2s ease;
transition: all 0.15s ease;
font-size: 16px;
font-family: var(--font-family);
flex-shrink: 0;
}
.refreshButton:hover:not(:disabled) {
background: var(--color-secondary);
background: var(--color-gray, #718096);
color: white;
border-color: var(--color-secondary);
border-color: var(--color-gray, #718096);
}
.refreshButton:disabled {
@ -133,7 +133,7 @@
left: 12px;
top: -8px;
transform: translateY(0);
color: var(--color-secondary);
color: var(--color-text, #2D3748);
font-size: 12px;
pointer-events: none;
transition: all 0.3s ease;
@ -147,19 +147,19 @@
width: 100%;
height: 40px;
padding: 8px 12px;
border: 1px solid var(--color-primary);
border-radius: 25px;
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
transition: all 0.2s ease;
transition: all 0.15s ease;
box-sizing: border-box;
}
.searchInput:focus {
border-color: var(--color-secondary);
box-shadow: 0 0 0 2px rgba(var(--color-secondary), 0.1);
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb, 74, 111, 165), 0.15);
}
.searchInput::placeholder {
@ -193,15 +193,15 @@
width: 100%;
height: 40px;
padding: 6px 10px;
border: 1px solid var(--color-primary);
border-radius: 25px;
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
opacity: 0.6;
min-width: 120px;
transition: all 0.2s ease;
transition: all 0.15s ease;
box-sizing: border-box;
}
@ -209,7 +209,7 @@
outline: none;
border-color: var(--color-secondary);
opacity: 1;
box-shadow: 0 0 0 2px rgba(var(--color-secondary), 0.1);
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb, 74, 111, 165), 0.15);
}
.filterInput::placeholder {
@ -219,8 +219,8 @@
.filterSelect {
height: 40px;
padding: 6px 10px;
border: 1px solid var(--color-primary);
border-radius: 25px;
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
@ -239,7 +239,7 @@
/* Hide dropdown arrow when filter has a value */
.filterSelect.hasValue {
background-image: none;
color: var(--color-secondary);
color: var(--color-text);
border-color: var(--color-secondary);
opacity: 1;
}
@ -257,7 +257,7 @@
transform: translateY(-50%);
background: none;
border: none;
color: var(--color-primary);
color: var(--color-gray, #718096);
cursor: pointer;
font-size: 16px;
padding: 2px;
@ -345,8 +345,8 @@
.pageSizeSelect {
height: 32px;
padding: 4px 8px;
border: 1px solid var(--color-primary);
border-radius: 4px;
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px;
font-size: 14px;
font-family: var(--font-family);
background: var(--color-bg);
@ -361,13 +361,13 @@
}
.paginationButton {
width: 36px;
height: 36px;
width: 30px;
height: 30px;
padding: 0;
border: none;
background: var(--color-secondary);
color: white;
border-radius: 50%;
border: 1px solid var(--color-border, #E2E8F0);
background: var(--color-bg, #fff);
color: var(--color-text, #2D3748);
border-radius: 6px;
cursor: pointer;
font-family: var(--font-family);
transition: all 0.2s ease;
@ -380,7 +380,8 @@
}
.paginationButton:hover:not(:disabled) {
background: var(--color-secondary-hover);
background: var(--color-gray-disabled, #EDF2F7);
border-color: var(--color-gray, #718096);
}
.paginationButton:disabled {

View file

@ -16,7 +16,7 @@
.loadingSpinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-primary);
border: 2px solid var(--color-border, #E2E8F0);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
@ -51,10 +51,10 @@
.fieldInput {
width: 100%;
padding: 12px 12px 8px 12px;
border: 1px solid var(--color-primary);
border-radius: 25px;
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px;
font-size: 14px;
transition: all 0.2s ease;
transition: all 0.15s ease;
background-color: var(--color-bg);
box-sizing: border-box;
color: var(--color-text);
@ -79,8 +79,8 @@
.multiselectContainer {
width: 100%;
padding: 12px;
border: 1px solid var(--color-primary);
border-radius: 25px;
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px;
background-color: var(--color-bg);
min-height: 60px;
max-height: 200px;
@ -128,7 +128,7 @@
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--color-primary);
accent-color: var(--color-secondary);
}
.multiselectLabel {
@ -139,7 +139,7 @@
.multiselectCount {
font-size: 0.75rem;
color: var(--color-primary);
color: var(--color-secondary);
margin-left: 4px;
font-weight: normal;
}
@ -148,10 +148,10 @@
.fieldTextarea {
width: 100%;
padding: 12px 12px 8px 12px;
border: 1px solid var(--color-primary);
border-radius: 25px;
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px;
font-size: 14px;
transition: all 0.2s ease;
transition: all 0.15s ease;
background-color: var(--color-bg);
box-sizing: border-box;
color: var(--color-text);
@ -184,7 +184,7 @@
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--color-primary);
color: var(--color-gray, #718096);
opacity: 0.7;
font-size: 14px;
transition: all 0.2s ease;
@ -197,7 +197,7 @@
left: 12px;
top: -8px;
transform: translateY(0);
color: var(--color-primary);
color: var(--color-text, #2D3748);
opacity: 1;
font-size: 12px;
font-weight: 500;
@ -227,8 +227,8 @@
.readonlyField {
padding: 12px 12px 8px 12px;
background-color: var(--color-bg);
border: 1px solid var(--color-primary);
border-radius: 25px;
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px;
color: var(--color-text);
font-size: 14px;
min-height: 20px;
@ -252,7 +252,7 @@
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--color-primary);
accent-color: var(--color-secondary);
}
/* Required field indicator */
@ -276,15 +276,15 @@
gap: 12px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--color-primary);
border-top: 1px solid var(--color-border, #E2E8F0);
}
.cancelButton {
padding: 8px 16px;
border: 1px solid var(--color-primary);
border: 1px solid var(--color-border, #E2E8F0);
background-color: var(--color-bg);
color: var(--color-text);
border-radius: 25px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
@ -307,11 +307,11 @@
border: none;
background-color: var(--color-secondary);
color: var(--color-bg);
border-radius: 25px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
transition: all 0.15s ease;
}
.submitButton:hover:not(:disabled) {

View file

@ -4,12 +4,9 @@
gap: 10px;
width: 100%;
font-family: var(--font-family);
/* Fill available space and constrain height */
min-height: 0;
flex: 1;
/* Prevent overflow - constrain to parent height */
overflow: hidden;
/* Ensure container respects parent's height */
height: 100%;
max-height: 100%;
}
@ -22,62 +19,57 @@
margin-bottom: 10px;
}
/* Table wrapper - contains top scrollbar and table container */
/* Table wrapper */
.tableWrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
/* Constrain height to prevent growing beyond parent */
max-height: 100%;
overflow: hidden;
border: 1px solid var(--color-primary);
border-radius: 25px;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 10px;
background: var(--color-bg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
/* Top horizontal scrollbar - syncs with table container */
/* Top horizontal scrollbar */
.topScrollbar {
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
background: var(--color-bg);
border-bottom: 1px solid var(--color-primary);
border-radius: 25px 25px 0 0;
border-bottom: 1px solid var(--color-border, #e2e8f0);
border-radius: 10px 10px 0 0;
}
/* Inner div that matches table width for proper scrollbar sizing */
.topScrollbarInner {
height: 1px; /* Minimal height - just need width to activate scrollbar */
height: 1px;
}
/* Table Container - scrollable area for table data only (vertical only) */
/* Table Container */
.tableContainer {
position: relative;
overflow-x: hidden; /* Horizontal scroll handled by topScrollbar */
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
background: var(--color-bg);
/* Fill remaining space but constrain to available height */
flex: 1 1 0;
min-height: 0;
max-height: 100%;
border-radius: 0 0 25px 25px;
border-radius: 0 0 10px 10px;
}
/* Empty table styling - no extra space, just header */
.emptyTable {
min-height: auto;
height: auto;
max-height: none;
}
/* Hide top scrollbar when table is empty */
.emptyTable .topScrollbar {
display: none;
}
/* Empty state styling */
.emptyState {
display: flex;
align-items: center;
@ -86,13 +78,12 @@
padding: 40px 20px;
}
/* Empty message styling */
.emptyMessage {
text-align: center;
padding: 20px;
color: var(--color-text);
opacity: 0.7;
font-size: 1rem;
opacity: 0.5;
font-size: 0.9rem;
line-height: 1.5;
}
@ -101,23 +92,21 @@
justify-content: center;
align-items: center;
padding: 40px;
font-size: 16px;
color: var(--color-text);
font-size: 14px;
color: var(--color-text-secondary, #94a3b8);
}
/* Table Styles */
.table {
width: 100%;
/* Use separate borders for sticky header support */
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
font-size: 13px;
background: var(--color-bg);
table-layout: fixed;
word-wrap: break-word;
}
/* Disabled user row styling */
.table tbody tr[data-user-enabled="false"] {
opacity: 0.6 !important;
background-color: rgba(0, 0, 0, 0.02) !important;
@ -128,7 +117,6 @@
background-color: rgba(0, 0, 0, 0.05) !important;
}
/* Dark mode disabled user styling */
.dark .table tbody tr[data-user-enabled="false"] {
background-color: rgba(255, 255, 255, 0.02) !important;
}
@ -137,13 +125,11 @@
background-color: rgba(255, 255, 255, 0.05) !important;
}
/* Sticky thead for table header */
/* Sticky thead */
.table thead {
position: sticky;
top: 0;
z-index: 10;
/* Shadow to separate header from scrolled content */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.table thead tr {
@ -151,20 +137,19 @@
}
.th {
background: var(--color-bg);
padding: 6px 10px;
background: var(--color-bg, #f8fafc);
padding: 10px 12px;
text-align: left;
font-weight: 400;
font-size: 12px;
color: var(--color-text);
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #64748b);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
overflow: visible;
/* Border separates header from scrolled content */
border-bottom: 2px solid var(--color-primary);
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
.th.actionsColumn {
@ -173,11 +158,12 @@
.th.sortable {
cursor: pointer;
transition: background-color 0.2s ease;
transition: background-color 0.15s ease, color 0.15s ease;
}
.th.sortable:hover {
background: var(--color-gray-disabled);
background: var(--color-gray-disabled, #f1f5f9);
color: var(--color-text, #334155);
}
.headerContent {
@ -193,8 +179,8 @@
}
.sortIcon {
font-size: 12px;
color: var(--color-text-secondary, #999);
font-size: 11px;
color: var(--color-text-secondary, #94a3b8);
cursor: pointer;
padding: 2px;
display: inline-flex;
@ -203,7 +189,7 @@
}
.sortIcon:hover {
color: var(--color-secondary);
color: var(--color-text, #334155);
}
.sortIcon.sortActive {
@ -216,11 +202,11 @@
font-weight: 500;
}
/* Filter icon in column header */
/* Filter icon */
.filterIcon {
background: none;
border: none;
color: var(--color-text-secondary, #999);
color: var(--color-text-secondary, #94a3b8);
cursor: pointer;
padding: 2px;
display: flex;
@ -231,13 +217,13 @@
}
.filterIcon:hover {
color: var(--color-secondary);
background: rgba(var(--color-secondary-rgb), 0.1);
color: var(--color-text, #334155);
background: rgba(0, 0, 0, 0.05);
}
.filterIcon.filterActive {
color: var(--color-secondary);
background: rgba(var(--color-secondary-rgb), 0.15);
background: rgba(var(--color-secondary-rgb), 0.1);
}
/* Filter dropdown */
@ -248,9 +234,9 @@
min-width: 180px;
max-width: 300px;
background: var(--color-bg);
border: 1px solid var(--color-border, #ddd);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
z-index: 100;
margin-top: 4px;
}
@ -260,7 +246,7 @@
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border, #ddd);
border-bottom: 1px solid var(--color-border, #e2e8f0);
font-size: 12px;
font-weight: 500;
color: var(--color-text);
@ -269,7 +255,7 @@
.filterClearBtn {
background: none;
border: none;
color: var(--color-text-secondary);
color: var(--color-text-secondary, #94a3b8);
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
@ -277,8 +263,8 @@
}
.filterClearBtn:hover {
background: rgba(255, 0, 0, 0.1);
color: #c00;
background: rgba(0, 0, 0, 0.06);
color: var(--color-text, #334155);
}
.filterDropdownOptions {
@ -298,23 +284,23 @@
}
.filterOption:hover {
background: var(--color-gray-disabled, #f5f5f5);
background: var(--color-gray-disabled, #f1f5f9);
}
.filterOptionSelected {
background: rgba(var(--color-secondary-rgb), 0.15);
background: rgba(var(--color-secondary-rgb), 0.08);
color: var(--color-secondary);
font-weight: 500;
}
.filterOptionSelected:hover {
background: rgba(var(--color-secondary-rgb), 0.2);
background: rgba(var(--color-secondary-rgb), 0.12);
}
.filterOptionMore {
padding: 6px 12px;
font-size: 11px;
color: var(--color-text-secondary);
color: var(--color-text-secondary, #94a3b8);
font-style: italic;
}
@ -330,21 +316,21 @@
}
.resizeHandle:hover {
background: var(--color-secondary);
opacity: 0.5;
background: var(--color-border, #cbd5e1);
}
.resizeHandle:active {
background: var(--color-secondary);
opacity: 0.8;
opacity: 0.6;
}
/* Table cells */
.td {
padding: 4px 10px;
border-top: 1px solid var(--color-primary);
padding: 8px 12px;
border-top: 1px solid var(--color-border, #f1f5f9);
color: var(--color-text);
font-weight: 400;
font-size: 12px;
font-size: 13px;
vertical-align: middle;
word-wrap: break-word;
overflow-wrap: break-word;
@ -353,23 +339,31 @@
overflow: visible;
}
/* FK Loading state - shows truncated ID while loading */
.fkLoading {
color: var(--color-text);
opacity: 0.6;
font-style: italic;
}
/* Rows */
.tr {
transition: background-color 0.2s ease;
transition: background-color 0.12s ease;
}
.tr:hover {
background: transparent;
background: var(--color-gray-disabled, #f8fafc);
}
.tr:nth-child(even) {
background: rgba(0, 0, 0, 0.015);
}
.tr:nth-child(even):hover {
background: var(--color-gray-disabled, #f8fafc);
}
.tr.selected {
background: rgba(var(--color-secondary-rgb), 0.1);
background: rgba(var(--color-secondary-rgb), 0.08);
}
.tr.clickable {
@ -384,14 +378,12 @@
position: relative;
}
/* Selection column header - background inherited from thead */
thead .selectColumn {
background: var(--color-bg);
}
/* Selection Column border only on body cells, not header */
tbody .selectColumn {
border-top: 1px solid var(--color-primary);
border-top: 1px solid var(--color-border, #f1f5f9);
}
.selectColumn input[type="checkbox"] {
@ -402,7 +394,7 @@ tbody .selectColumn {
accent-color: var(--color-secondary);
margin: 0;
padding: 0;
border: 2px solid var(--color-primary);
border: 1.5px solid var(--color-border, #cbd5e1);
border-radius: 3px;
background: var(--color-bg);
position: relative;
@ -426,7 +418,7 @@ tbody .selectColumn {
outline-offset: 2px;
}
/* Actions Column - Fixed width like select column */
/* Actions Column */
.actionsColumn {
white-space: nowrap;
text-align: center;
@ -437,27 +429,24 @@ tbody .selectColumn {
position: relative;
}
/* Actions column header - background inherited from thead */
thead .actionsColumn {
background: var(--color-bg);
}
/* Actions Column border only on body cells, not header */
tbody .actionsColumn {
border-top: 1px solid var(--color-primary);
border-top: 1px solid var(--color-border, #f1f5f9);
}
.actionButtons {
display: flex;
flex-wrap: nowrap;
gap: 2px;
gap: 4px;
justify-content: center;
align-items: center;
width: 100%;
margin: 0 auto;
}
/* Enable wrapping only when column exceeds 20% of container width */
.actionButtonsWrap {
flex-wrap: wrap;
}
@ -466,29 +455,31 @@ tbody .actionsColumn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
padding: 5px;
border: none;
border-radius: 50%;
border-radius: 6px;
font-size: 11px;
font-family: var(--font-family);
cursor: pointer;
transition: all 0.2s ease;
transition: all 0.15s ease;
white-space: nowrap;
position: relative;
min-width: 24px;
min-height: 24px;
background: var(--color-secondary);
color: var(--color-bg);
min-width: 26px;
min-height: 26px;
background: var(--color-text-secondary, #64748b);
color: #fff;
}
.actionButton:hover {
background: var(--color-secondary-hover);
background: var(--color-text, #334155);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
}
.actionIcon {
font-size: 14px;
height: 14px;
width: 14px;
font-size: 13px;
height: 13px;
width: 13px;
display: flex;
align-items: center;
justify-content: center;
@ -500,8 +491,8 @@ tbody .actionsColumn {
gap: 2px;
justify-content: center;
align-items: center;
background: var(--color-secondary);
border-radius: 25px;
background: var(--color-text-secondary, #64748b);
border-radius: 8px;
}
.confirmButton {
@ -510,8 +501,8 @@ tbody .actionsColumn {
}
.confirmButton:hover {
background: transparent !important;
transform: scale(1.05);
background: rgba(255, 255, 255, 0.15) !important;
transform: none;
}
.cancelButton {
@ -520,23 +511,24 @@ tbody .actionsColumn {
}
.cancelButton:hover {
background: transparent !important;
transform: scale(1.05);
background: rgba(255, 255, 255, 0.15) !important;
transform: none;
}
.actionButton:disabled {
opacity: 0.6;
opacity: 0.4;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
/* Pagination */
.pagination {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
padding: 8px 0;
/* Ensure pagination stays visible and doesn't get cut off */
gap: 8px;
padding: 10px 0;
flex-shrink: 0;
flex-wrap: wrap;
background: var(--color-bg);
@ -546,8 +538,8 @@ tbody .actionsColumn {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--color-text);
font-size: 13px;
color: var(--color-text-secondary, #64748b);
}
.pageSizeSelector label {
@ -556,11 +548,11 @@ tbody .actionsColumn {
}
.pageSizeSelect {
height: 32px;
height: 30px;
padding: 4px 8px;
border: 1px solid var(--color-primary);
border-radius: 4px;
font-size: 14px;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
font-size: 13px;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
@ -571,43 +563,45 @@ tbody .actionsColumn {
.pageSizeSelect:focus {
outline: none;
border-color: var(--color-secondary);
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15);
}
.paginationButton {
width: 36px;
height: 36px;
width: 30px;
height: 30px;
padding: 0;
border: none;
background: var(--color-secondary);
color: white;
border-radius: 50%;
border: 1px solid var(--color-border, #e2e8f0);
background: var(--color-bg, #fff);
color: var(--color-text, #334155);
border-radius: 6px;
cursor: pointer;
font-family: var(--font-family);
transition: all 0.2s ease;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-size: 14px;
font-weight: 500;
}
.paginationButton:hover:not(:disabled) {
background: var(--color-secondary-hover);
background: var(--color-gray-disabled, #f1f5f9);
border-color: var(--color-text-secondary, #94a3b8);
}
.paginationButton:disabled {
opacity: 0.5;
opacity: 0.35;
cursor: not-allowed;
}
.paginationInfo {
font-size: 14px;
color: var(--color-text);
margin: 0 15px;
font-size: 13px;
color: var(--color-text-secondary, #64748b);
margin: 0 8px;
white-space: nowrap;
}
/* Page numbers container */
/* Page numbers */
.pageNumbers {
display: flex;
flex-wrap: wrap;
@ -617,50 +611,51 @@ tbody .actionsColumn {
max-width: 60vw;
max-height: 120px;
overflow-y: auto;
padding: 4px;
padding: 2px;
}
/* Individual page number button */
.pageNumber {
min-width: 28px;
height: 28px;
padding: 0 6px;
border: 1px solid var(--color-border, #ddd);
border: 1px solid var(--color-border, #e2e8f0);
background: var(--color-bg, #fff);
color: var(--color-text);
border-radius: 4px;
border-radius: 6px;
cursor: pointer;
font-family: var(--font-family);
font-size: 12px;
transition: all 0.15s ease;
transition: all 0.12s ease;
display: flex;
align-items: center;
justify-content: center;
}
.pageNumber:hover:not(:disabled) {
background: var(--color-secondary);
color: white;
border-color: var(--color-secondary);
background: var(--color-gray-disabled, #f1f5f9);
border-color: var(--color-text-secondary, #94a3b8);
}
.pageNumber:disabled {
cursor: default;
}
/* Active/current page number */
.pageNumberActive {
background: var(--color-secondary);
background: var(--color-text, #334155);
color: white;
border-color: var(--color-secondary);
border-color: var(--color-text, #334155);
font-weight: 600;
}
/* Ellipsis indicator */
.pageNumberActive:hover:not(:disabled) {
background: var(--color-text, #1e293b);
border-color: var(--color-text, #1e293b);
}
.pageEllipsis {
padding: 0 8px;
color: var(--color-text-secondary, #666);
font-size: 14px;
padding: 0 6px;
color: var(--color-text-secondary, #94a3b8);
font-size: 13px;
}
/* Loading overlay */
@ -676,16 +671,16 @@ tbody .actionsColumn {
align-items: center;
justify-content: center;
z-index: 10;
border-radius: 8px;
border-radius: 10px;
}
.loadingOverlay p {
margin-top: 12px;
color: var(--color-text-secondary, #666);
font-size: 14px;
color: var(--color-text-secondary, #64748b);
font-size: 13px;
}
/* Responsive Design */
/* Responsive */
@media (max-width: 768px) {
.tableContainer {
flex: 1;
@ -693,7 +688,6 @@ tbody .actionsColumn {
max-height: 100%;
}
/* Empty table styling - no extra space */
.emptyTable {
min-height: auto;
height: auto;
@ -701,8 +695,8 @@ tbody .actionsColumn {
.th,
.td {
padding: 4px 8px;
font-size: 11px;
padding: 6px 8px;
font-size: 12px;
}
.actionButtons {
@ -711,7 +705,7 @@ tbody .actionsColumn {
}
.actionButton {
padding: 3px;
padding: 4px;
font-size: 10px;
min-width: 22px;
min-height: 22px;
@ -731,7 +725,7 @@ tbody .actionsColumn {
.paginationInfo {
text-align: center;
margin: 0;
font-size: 13px;
font-size: 12px;
}
.pageNumbers {
@ -746,18 +740,22 @@ tbody .actionsColumn {
}
}
/* Dark theme support */
/* Dark theme */
@media (prefers-color-scheme: dark) {
.th.sortable:hover {
background: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
}
.tr:hover {
background: transparent;
background: rgba(255, 255, 255, 0.04);
}
.tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
.tr.selected {
background: rgba(var(--color-secondary-rgb), 0.2);
background: rgba(var(--color-secondary-rgb), 0.15);
}
.loadingOverlay {
@ -774,45 +772,43 @@ tbody .actionsColumn {
.actionButton:focus,
.paginationButton:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.3);
}
/* Custom scrollbar for table container (vertical only) */
/* Scrollbar */
.tableContainer::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
.tableContainer::-webkit-scrollbar-track {
background: var(--color-gray-disabled);
border-radius: 4px;
background: transparent;
}
.tableContainer::-webkit-scrollbar-thumb {
background: var(--color-gray);
border-radius: 4px;
background: var(--color-border, #cbd5e1);
border-radius: 3px;
}
.tableContainer::-webkit-scrollbar-thumb:hover {
background: var(--color-secondary);
background: var(--color-text-secondary, #94a3b8);
}
/* Custom scrollbar for top scrollbar (horizontal only) */
.topScrollbar::-webkit-scrollbar {
height: 8px;
height: 6px;
}
.topScrollbar::-webkit-scrollbar-track {
background: var(--color-gray-disabled);
border-radius: 4px;
background: transparent;
}
.topScrollbar::-webkit-scrollbar-thumb {
background: var(--color-gray);
border-radius: 4px;
background: var(--color-border, #cbd5e1);
border-radius: 3px;
}
.topScrollbar::-webkit-scrollbar-thumb:hover {
background: var(--color-secondary);
background: var(--color-text-secondary, #94a3b8);
}
/* Loading State */
@ -823,16 +819,16 @@ tbody .actionsColumn {
justify-content: center;
padding: 4rem 2rem;
text-align: center;
color: var(--color-text-secondary, #666);
color: var(--color-text-secondary, #64748b);
}
.loadingSpinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-bg-secondary, #e9ecef);
border-top: 3px solid var(--color-primary, #007bff);
width: 32px;
height: 32px;
border: 2px solid var(--color-border, #e2e8f0);
border-top: 2px solid var(--color-text-secondary, #64748b);
border-radius: 50%;
animation: spin 1s linear infinite;
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@ -841,16 +837,16 @@ tbody .actionsColumn {
100% { transform: rotate(360deg); }
}
/* Inline Editable Boolean Cells */
/* Boolean Cells */
.booleanCell {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
font-size: 16px;
width: 26px;
height: 26px;
font-size: 14px;
font-weight: bold;
border-radius: 4px;
border-radius: 6px;
transition: all 0.15s ease;
user-select: none;
}
@ -858,12 +854,12 @@ tbody .actionsColumn {
.booleanEditable {
cursor: pointer;
background: transparent;
border: 2px solid var(--color-border, #dee2e6);
border: 1.5px solid var(--color-border, #e2e8f0);
}
.booleanEditable:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.booleanEditable:active {
@ -871,25 +867,25 @@ tbody .actionsColumn {
}
.booleanEditable.booleanTrue {
color: var(--color-success, #28a745);
border-color: var(--color-success, #28a745);
background: rgba(40, 167, 69, 0.1);
color: var(--color-success, #16a34a);
border-color: var(--color-success, #16a34a);
background: rgba(22, 163, 74, 0.08);
}
.booleanEditable.booleanTrue:hover {
background: rgba(40, 167, 69, 0.2);
background: rgba(22, 163, 74, 0.15);
}
.booleanEditable.booleanFalse {
color: var(--color-text-secondary, #6c757d);
border-color: var(--color-border, #dee2e6);
color: var(--color-text-secondary, #94a3b8);
border-color: var(--color-border, #e2e8f0);
background: transparent;
}
.booleanEditable.booleanFalse:hover {
color: var(--color-danger, #dc3545);
border-color: var(--color-danger, #dc3545);
background: rgba(220, 53, 69, 0.1);
color: var(--color-text, #334155);
border-color: var(--color-text-secondary, #94a3b8);
background: rgba(0, 0, 0, 0.03);
}
.booleanReadonly {
@ -900,11 +896,11 @@ tbody .actionsColumn {
}
.booleanReadonly.booleanTrue {
color: var(--color-success, #28a745);
color: var(--color-success, #16a34a);
}
.booleanReadonly.booleanFalse {
color: var(--color-text-secondary, #adb5bd);
color: var(--color-text-secondary, #94a3b8);
}
.booleanLoading {
@ -912,12 +908,12 @@ tbody .actionsColumn {
align-items: center;
justify-content: center;
animation: booleanPulse 1s ease-in-out infinite;
color: var(--color-primary, #007bff);
color: var(--color-text-secondary, #64748b);
font-size: 14px;
}
@keyframes booleanPulse {
0%, 100% { opacity: 0.4; }
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
@ -928,7 +924,7 @@ tbody .actionsColumn {
}
.groupHeader:hover {
background-color: var(--bg-hover, #f1f5f9) !important;
background-color: var(--color-gray-disabled, #f1f5f9) !important;
}
.groupHeader .td {
@ -943,13 +939,13 @@ tbody .actionsColumn {
width: 18px;
height: 18px;
font-size: 10px;
color: var(--text-muted, #64748b);
color: var(--color-text-secondary, #64748b);
transition: transform 0.2s ease;
}
.groupCount {
font-size: 11px;
color: var(--text-muted, #64748b);
color: var(--color-text-secondary, #94a3b8);
font-weight: 400;
margin-left: 4px;
}
@ -959,5 +955,3 @@ tbody .actionsColumn {
gap: 4px;
align-items: center;
}

View file

@ -9,8 +9,8 @@
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
border: 1px solid var(--color-primary);
border-radius: 25px;
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px;
font-size: 14px;
font-family: var(--font-family);
cursor: pointer;
@ -23,13 +23,13 @@
.dropdownButton:hover:not(.disabled):not(:disabled) {
border-color: var(--color-secondary);
background-color: var(--color-secondary);
color: white;
background-color: var(--color-bg);
color: var(--color-text);
}
.dropdownButton:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 0, 123, 255), 0.1);
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb, 74, 111, 165), 0.15);
}
.dropdownButton.disabled,
@ -146,7 +146,7 @@
.buttonSpinner {
width: 16px;
height: 16px;
border: 2px solid var(--color-primary);
border: 2px solid var(--color-border, #E2E8F0);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
@ -164,7 +164,7 @@
left: 0;
right: 0;
background-color: var(--color-bg);
border: 1px solid var(--color-primary);
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
@ -178,7 +178,7 @@
font-weight: 600;
color: var(--color-text);
background-color: var(--color-bg);
border-bottom: 1px solid var(--color-primary);
border-bottom: 1px solid var(--color-border, #E2E8F0);
text-transform: uppercase;
letter-spacing: 0.5px;
}
@ -205,7 +205,7 @@
cursor: pointer;
font-family: var(--font-family);
transition: all 0.2s ease;
border-bottom: 1px solid var(--color-primary);
border-bottom: 1px solid var(--color-border, #E2E8F0);
color: var(--color-text);
display: flex;
align-items: center;
@ -218,19 +218,18 @@
}
.dropdownItem:hover {
background-color: var(--color-secondary);
color: white;
background-color: var(--color-highlight-gray, #F7FAFC);
color: var(--color-text);
}
.dropdownItemSelected {
background-color: var(--color-primary);
color: white;
background-color: rgba(var(--color-secondary-rgb, 74, 111, 165), 0.1);
color: var(--color-secondary);
font-weight: 500;
}
.dropdownItemSelected:hover {
background-color: var(--color-primary);
opacity: 0.9;
background-color: rgba(var(--color-secondary-rgb, 74, 111, 165), 0.15);
}
.itemLabel {

View file

@ -15,10 +15,10 @@
/* Main popup container */
.popup {
border: 1px solid var(--color-primary);
border: 1px solid var(--color-border, #E2E8F0);
background: var(--color-bg);
border-radius: 25px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
max-height: 90vh;
display: flex;
flex-direction: column;
@ -64,7 +64,7 @@
/* Header section */
.header {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--color-primary);
border-bottom: 1px solid var(--color-border, #E2E8F0);
display: flex;
justify-content: space-between;
align-items: center;
@ -101,13 +101,13 @@
gap: 6px;
padding: 15px;
border: none;
border-radius: 25px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
transition: all 0.15s ease;
white-space: nowrap;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: var(--font-family);
background: var(--color-secondary);
color: white;
}
@ -140,13 +140,13 @@
}
.closeButton {
background: var(--color-primary);
background: var(--color-gray-disabled, #CBD5E0);
border: none;
font-size: 18px;
color: #3A3A3A;
color: var(--color-text, #2D3748);
cursor: pointer;
padding: 8px;
border-radius: 25px;
border-radius: 6px;
transition: all 0.2s ease;
line-height: 1;
margin-left: 8px;
@ -158,8 +158,8 @@
}
.closeButton:hover {
background-color: var(--color-primary-hover);
color: #3A3A3A;
background-color: var(--color-gray, #718096);
color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
@ -173,7 +173,7 @@
/* Footer section */
.footer {
padding: 16px 24px;
border-top: 1px solid var(--color-primary);
border-top: 1px solid var(--color-border, #E2E8F0);
display: flex;
justify-content: flex-end;
gap: 12px;

View file

@ -30,8 +30,8 @@
.textarea {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--color-primary);
border-radius: 25px;
border: 1px solid var(--color-border, #E2E8F0);
border-radius: 6px;
font-size: 14px;
font-family: inherit;
color: var(--color-text);
@ -54,7 +54,7 @@
.textarea:focus {
outline: none;
border-color: var(--color-secondary);
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb, 0, 123, 255), 0.1);
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb, 74, 111, 165), 0.15);
}
.input:disabled,

View file

@ -221,6 +221,9 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return null;
}
// GraphicalEditor sub-pages have their own headers with actions; skip the wrapper title.
const _skipViewHeader = featureCode === 'graphicalEditor';
// View-Komponente finden
const featureViews = VIEW_COMPONENTS[featureCode];
if (!featureViews) {
@ -240,9 +243,11 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return (
<div className={styles.featureView}>
{!_skipViewHeader && (
<header className={styles.viewHeader}>
<h1 className={styles.viewTitle}>{viewLabel}</h1>
</header>
)}
<main className={styles.viewContent}>
<ViewComponent />
</main>

View file

@ -47,8 +47,8 @@
margin-top: 2rem;
padding: 2rem;
border-radius: 25px;
border: 1px solid color-mix(in srgb, var(--color-primary) 15%, transparent);
border-radius: 10px;
border: 1px solid var(--color-border, #E2E8F0);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02),
0 0 10px rgba(0, 0, 0, 0.1);
}
@ -73,7 +73,7 @@
left: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--color-primary);
color: var(--color-gray, #718096);
font-size: 1rem;
pointer-events: none;
transition: all 0.3s ease;
@ -101,10 +101,10 @@
height: 50px;
padding: 12px 16px;
border: 1px solid var(--color-gray-disabled);
border-radius: 25px;
border-radius: 6px;
font-size: 1rem;
transition: all 0.2s ease;
transition: all 0.15s ease;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
@ -147,7 +147,7 @@
width: 100%;
height: 50px;
padding: 12px 20px;
border-radius: 25px;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
@ -171,7 +171,7 @@
.loginButton {
background-color: var(--color-secondary);
color: var(--color-text);
color: #fff;
}
.loginButton:hover {
@ -179,21 +179,23 @@
}
.microsoftButton {
background-color: var(--color-primary);
color: var(--color-bg);
background-color: var(--color-gray-disabled, #CBD5E0);
color: var(--color-text);
}
.microsoftButton:hover {
background-color: var(--color-primary-hover);
background-color: var(--color-gray, #718096);
color: #fff;
}
.googleButton {
background-color: var(--color-primary);
color: var(--color-bg);
background-color: var(--color-gray-disabled, #CBD5E0);
color: var(--color-text);
}
.googleButton:hover {
background-color: var(--color-primary-hover);
background-color: var(--color-gray, #718096);
color: #fff;
}
.divider {
@ -252,13 +254,13 @@
flex: 1;
height: 46px;
padding: 10px 16px;
border-radius: 25px;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
border: none;
background-color: var(--color-secondary);
color: var(--color-text);
color: #fff;
transition: all 0.2s ease;
font-family: var(--font-family);
}
@ -271,7 +273,7 @@
flex: 1;
height: 46px;
padding: 10px 16px;
border-radius: 25px;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
@ -349,7 +351,7 @@ button:disabled {
width: 100%;
margin-top: 1.25rem;
padding: 1.25rem;
border-radius: 20px;
border-radius: 10px;
}
.registerLink {
@ -365,7 +367,7 @@ button:disabled {
.loginBox {
padding: 1rem;
border-radius: 16px;
border-radius: 10px;
}
.input,

View file

@ -47,8 +47,8 @@
margin-top: 2rem;
padding: 2rem;
border-radius: 25px;
border: 1px solid color-mix(in srgb, var(--color-primary) 15%, transparent);
border-radius: 8px;
border: 1px solid var(--color-border, #E2E8F0);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02),
0 0 10px rgba(0, 0, 0, 0.1);
}
@ -105,7 +105,7 @@
height: 50px;
padding: 12px 16px;
border: 1px solid var(--color-gray-disabled);
border-radius: 25px;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s ease;
@ -145,7 +145,7 @@
width: 100%;
height: 50px;
padding: 12px 20px;
border-radius: 25px;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
@ -200,7 +200,7 @@ button:disabled {
color: var(--color-secondary);
background-color: var(--color-secondary-disabled);
border: 1px solid var(--color-secondary);
border-radius: 25px;
border-radius: 8px;
padding: 12px;
font-size: 0.9rem;
text-align: center;
@ -212,7 +212,7 @@ button:disabled {
color: var(--color-success);
background-color: color-mix(in srgb, var(--color-success) 10%, transparent);
border: 1px solid var(--color-success);
border-radius: 25px;
border-radius: 8px;
padding: 12px;
font-size: 0.9rem;
text-align: center;

View file

@ -47,8 +47,8 @@
margin-top: 2rem;
padding: 2rem;
border-radius: 25px;
border: 1px solid color-mix(in srgb, var(--color-primary) 15%, transparent);
border-radius: 8px;
border: 1px solid var(--color-border, #E2E8F0);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02),
0 0 10px rgba(0, 0, 0, 0.1);
}
@ -101,7 +101,7 @@
height: 50px;
padding: 12px 16px;
border: 1px solid var(--color-gray-disabled);
border-radius: 25px;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s ease;
@ -152,7 +152,7 @@
width: 100%;
height: 50px;
padding: 12px 20px;
border-radius: 25px;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
@ -214,7 +214,7 @@ button:disabled {
color: var(--color-secondary);
background-color: var(--color-secondary-disabled);
border: 1px solid var(--color-secondary);
border-radius: 25px;
border-radius: 8px;
padding: 12px;
font-size: 0.9rem;
text-align: center;
@ -226,7 +226,7 @@ button:disabled {
color: var(--color-success);
background-color: color-mix(in srgb, var(--color-success) 10%, transparent);
border: 1px solid var(--color-success);
border-radius: 25px;
border-radius: 8px;
padding: 12px;
font-size: 0.9rem;
text-align: center;

View file

@ -47,8 +47,8 @@
margin-top: 2rem;
padding: 2rem;
border-radius: 25px;
border: 1px solid color-mix(in srgb, var(--color-primary) 15%, transparent);
border-radius: 8px;
border: 1px solid var(--color-border, #E2E8F0);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02),
0 0 10px rgba(0, 0, 0, 0.1);
}
@ -106,7 +106,7 @@
height: 50px;
padding: 12px 16px;
border: 1px solid var(--color-gray-disabled);
border-radius: 25px;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s ease;
@ -153,7 +153,7 @@
width: 100%;
height: 50px;
padding: 12px 20px;
border-radius: 25px;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
@ -209,7 +209,7 @@ button:disabled {
color: var(--color-secondary);
background-color: var(--color-secondary-disabled);
border: 1px solid var(--color-secondary);
border-radius: 25px;
border-radius: 8px;
padding: 12px;
font-size: 0.9rem;
text-align: center;
@ -221,7 +221,7 @@ button:disabled {
color: var(--color-success);
background-color: color-mix(in srgb, var(--color-success) 10%, transparent);
border: 1px solid var(--color-success);
border-radius: 25px;
border-radius: 8px;
padding: 12px;
font-size: 0.9rem;
text-align: center;

View file

@ -6,7 +6,7 @@
*/
import React, { useState, useCallback, useEffect } from 'react';
import { FaSync, FaPlay, FaCog, FaClipboardList, FaChartBar } from 'react-icons/fa';
import { FaSync, FaPlay, FaCog, FaClipboardList, FaChartBar, FaDownload } from 'react-icons/fa';
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
@ -110,6 +110,36 @@ export const GraphicalEditorDashboardPage: React.FC = () => {
load();
}, [load]);
const _downloadRunTracing = useCallback(async (run: CompletedRun) => {
if (!instanceId || !run.id) return;
try {
const data = await request({
url: `/api/workflows/${instanceId}/runs/${run.id}/steps`,
method: 'get',
});
const steps = data?.steps || [];
const report = {
runId: run.id,
workflowId: run.workflowId,
workflowLabel: run.workflowLabel,
status: run.status,
startedAt: _formatTs(run.sysCreatedAt),
endedAt: _formatTs(run.sysModifiedAt),
steps,
};
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `run-tracing-${run.id.slice(0, 8)}.json`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
console.error('[dashboard] download tracing failed', e);
showError('Download fehlgeschlagen');
}
}, [instanceId, request, showError]);
const runColumns: ColumnConfig[] = [
{
key: 'workflowLabel',
@ -144,6 +174,26 @@ export const GraphicalEditorDashboardPage: React.FC = () => {
width: 150,
formatter: (v: number) => _formatTs(v),
},
{
key: 'id',
label: '',
type: 'string',
width: 50,
sortable: false,
formatter: (_v: string, row: CompletedRun) => (
<button
onClick={(e) => { e.stopPropagation(); _downloadRunTracing(row); }}
title="Tracing-Protokoll herunterladen"
style={{
border: 'none', background: 'transparent', cursor: 'pointer',
color: 'var(--text-secondary, #666)', fontSize: 14, padding: 4,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<FaDownload />
</button>
),
},
];
if (!instanceId) {

View file

@ -1,25 +1,16 @@
/**
* GraphicalEditorPage
*
* 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)
* Thin wrapper: passes instance context to FlowEditor which now owns the full layout
* including the Workspace panel (Chats/Dateien/Quellen) on the left.
*/
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useRef } 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 {
persistentInstanceId?: string;
@ -35,22 +26,24 @@ export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
const instanceId = persistentInstanceId || urlInstanceId;
const mandateId = persistentMandateId || urlMandateId;
const [searchParams] = useSearchParams();
const initialWorkflowIdRef = useRef(searchParams.get('workflowId'));
const workflowIdFromUrl = searchParams.get('workflowId');
const [activeWorkflowId, setActiveWorkflowId] = useState<string | null>(workflowIdFromUrl);
const prevWorkflowIdRef = useRef(workflowIdFromUrl);
useEffect(() => {
if (workflowIdFromUrl && workflowIdFromUrl !== prevWorkflowIdRef.current) {
prevWorkflowIdRef.current = workflowIdFromUrl;
setActiveWorkflowId(workflowIdFromUrl);
}
}, [workflowIdFromUrl]);
const { currentLanguage } = useLanguage();
const language = (currentLanguage?.slice(0, 2) || 'de') as string;
const [udbTab, setUdbTab] = useState<UdbTab>('files');
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 || '',
}), [instanceId, mandateId]);
useEffect(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/datasources`)
@ -108,7 +101,7 @@ export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
if (!instanceId) {
return (
<div className={styles.placeholder}>
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h2>Graphical Editor</h2>
<p>Keine Feature-Instanz gefunden.</p>
</div>
@ -116,75 +109,19 @@ export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
}
return (
<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' }}>
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
<FlowEditor
instanceId={instanceId}
mandateId={mandateId || undefined}
language={language}
initialWorkflowId={initialWorkflowIdRef.current}
initialWorkflowId={activeWorkflowId}
pendingFiles={pendingFiles}
onRemovePendingFile={_handleRemovePendingFile}
dataSources={dataSources}
featureDataSources={featureDataSources}
onFileSelect={_handleFileSelect}
onSourcesChanged={_handleSourcesChanged}
/>
</div>
</div>
);
};

View file

@ -8,7 +8,8 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { FaCopy, FaSync, FaShareAlt } from 'react-icons/fa';
import { FaCopy, FaSync, FaShareAlt, FaPen } from 'react-icons/fa';
import { usePrompt } from '../../../hooks/usePrompt';
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
@ -17,6 +18,7 @@ import {
copyTemplate,
shareTemplate,
deleteWorkflow,
updateWorkflow,
type AutoWorkflowTemplate,
type AutoTemplateScope,
} from '../../../api/workflowApi';
@ -50,6 +52,7 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
const { request } = useApiRequest();
const navigate = useNavigate();
const { showSuccess, showError } = useToast();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [templates, setTemplates] = useState<AutoWorkflowTemplate[]>([]);
const [loading, setLoading] = useState(true);
@ -117,18 +120,15 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
);
const handleShare = useCallback(
async (row: AutoWorkflowTemplate) => {
async (row: AutoWorkflowTemplate, targetScope: AutoTemplateScope) => {
if (!instanceId) return;
const currentScope = row.templateScope || 'user';
const nextScope: AutoTemplateScope =
currentScope === 'user' ? 'instance' : currentScope === 'instance' ? 'mandate' : 'mandate';
setSharingId(row.id);
try {
await shareTemplate(request, instanceId, row.id, nextScope);
showSuccess(`Vorlage freigegeben (Scope: ${SCOPE_LABELS[nextScope]})`);
await shareTemplate(request, instanceId, row.id, targetScope);
showSuccess(`Scope geändert: ${SCOPE_LABELS[targetScope]}`);
await load();
} catch (e: any) {
showError(`Fehler: ${e?.message || 'Freigabe fehlgeschlagen'}`);
showError(`Fehler: ${e?.message || 'Scope-Änderung fehlgeschlagen'}`);
} finally {
setSharingId(null);
}
@ -136,6 +136,28 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
[instanceId, request, showSuccess, showError, load]
);
const [scopeMenuId, setScopeMenuId] = useState<string | null>(null);
const handleRename = useCallback(
async (row: AutoWorkflowTemplate) => {
if (!instanceId) return;
const newLabel = await promptInput('Neuer Name:', {
title: 'Vorlage umbenennen',
defaultValue: row.label,
placeholder: 'Vorlagen-Name',
});
if (!newLabel || newLabel.trim() === row.label) return;
try {
await updateWorkflow(request, instanceId, row.id, { label: newLabel.trim() });
showSuccess('Vorlage umbenannt');
await load();
} catch (e: any) {
showError(`Fehler: ${e?.message || 'Umbenennen fehlgeschlagen'}`);
}
},
[instanceId, request, promptInput, showSuccess, showError, load]
);
const handleEdit = useCallback(
(row: AutoWorkflowTemplate) => {
if (!mandateId || !instanceId) return;
@ -243,6 +265,13 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
},
]}
customActions={[
{
id: 'rename',
icon: <FaPen />,
title: 'Umbenennen',
onClick: (row) => handleRename(row),
visible: (row) => (row.templateScope || 'user') !== 'system',
},
{
id: 'copy',
icon: <FaCopy />,
@ -251,10 +280,10 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
loading: (row) => copyingId === row.id,
},
{
id: 'share',
id: 'scope',
icon: <FaShareAlt />,
title: 'Scope erweitern (freigeben)',
onClick: (row) => handleShare(row),
title: 'Scope ändern',
onClick: (row) => setScopeMenuId(scopeMenuId === row.id ? null : row.id),
loading: (row) => sharingId === row.id,
visible: (row) => (row.templateScope || 'user') !== 'system',
},
@ -264,6 +293,59 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
emptyMessage="Keine Vorlagen gefunden. Erstelle eine Vorlage aus einem bestehenden Workflow."
/>
</div>
{/* Scope change dropdown overlay */}
{scopeMenuId && (() => {
const tpl = templates.find(t => t.id === scopeMenuId);
if (!tpl) return null;
const currentScope = (tpl.templateScope || 'user') as AutoTemplateScope;
const scopes: AutoTemplateScope[] = ['user', 'instance', 'mandate'];
return (
<div
style={{ position: 'fixed', inset: 0, zIndex: 1000 }}
onClick={() => setScopeMenuId(null)}
>
<div
style={{
position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: 16, minWidth: 220,
}}
onClick={(e) => e.stopPropagation()}
>
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>Scope ändern</h4>
<p style={{ margin: '0 0 12px', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
Aktuell: <strong>{SCOPE_LABELS[currentScope]}</strong>
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{scopes.map(s => (
<button
key={s}
onClick={() => { handleShare(tpl, s); setScopeMenuId(null); }}
disabled={s === currentScope || sharingId === tpl.id}
style={{
padding: '6px 12px', border: '1px solid var(--border-color, #ddd)',
borderRadius: 4, background: s === currentScope ? 'var(--bg-secondary, #f0f0f0)' : 'transparent',
cursor: s === currentScope ? 'default' : 'pointer', textAlign: 'left', fontSize: '0.85rem',
fontWeight: s === currentScope ? 600 : 400,
}}
>
{SCOPE_LABELS[s]} {s === currentScope && '(aktuell)'}
</button>
))}
</div>
<button
onClick={() => setScopeMenuId(null)}
style={{ marginTop: 12, padding: '4px 12px', border: '1px solid var(--border-color, #ddd)', borderRadius: 4, background: 'transparent', cursor: 'pointer', fontSize: '0.8rem' }}
>
Abbrechen
</button>
</div>
</div>
);
})()}
<PromptDialog />
</div>
);
};

View file

@ -8,7 +8,8 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { FaPlay, FaSync, FaCheck, FaBan } from 'react-icons/fa';
import { FaPlay, FaSync, FaCheck, FaBan, FaPen } from 'react-icons/fa';
import { usePrompt } from '../../../hooks/usePrompt';
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
@ -42,6 +43,7 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
const { request } = useApiRequest();
const navigate = useNavigate();
const { showSuccess, showError } = useToast();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [loading, setLoading] = useState(true);
@ -123,6 +125,26 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
[instanceId, request, showSuccess, showError, load]
);
const handleRename = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
const newLabel = await promptInput('Neuer Name:', {
title: 'Workflow umbenennen',
defaultValue: row.label,
placeholder: 'Workflow-Name',
});
if (!newLabel || newLabel.trim() === row.label) return;
try {
await updateWorkflow(request, instanceId, row.id, { label: newLabel.trim() });
showSuccess('Workflow umbenannt');
await load();
} catch (e: any) {
showError(`Fehler: ${e?.message || 'Umbenennen fehlgeschlagen'}`);
}
},
[instanceId, request, promptInput, showSuccess, showError, load]
);
const handleExecute = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
@ -282,6 +304,12 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
},
]}
customActions={[
{
id: 'rename',
icon: <FaPen />,
title: 'Umbenennen',
onClick: (row) => handleRename(row),
},
{
id: 'activate',
icon: <FaCheck />,
@ -312,6 +340,7 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
emptyMessage="Keine Workflows gefunden. Erstelle einen im Editor."
/>
</div>
<PromptDialog />
</div>
);
};

View file

@ -11,39 +11,39 @@
--button-secondary-bg-disabled: var(--color-gray-disabled);
--button-secondary-text: var(--color-text);
--button-danger-bg: #dc3545;
--button-danger-bg-hover: #c82333;
--button-danger-bg-disabled: #dc3545;
--button-danger-bg: var(--color-red, #C53030);
--button-danger-bg-hover: #9B2C2C;
--button-danger-bg-disabled: rgba(197, 48, 48, 0.4);
--button-danger-text: white;
--button-success-bg: #28a745;
--button-success-bg-hover: #218838;
--button-success-bg-disabled: #28a745;
--button-success-bg: var(--color-success, #38A169);
--button-success-bg-hover: #2F855A;
--button-success-bg-disabled: rgba(56, 161, 105, 0.4);
--button-success-text: white;
--button-warning-bg: #ffc107;
--button-warning-bg-hover: #e0a800;
--button-warning-bg-disabled: #ffc107;
--button-warning-text: #212529;
--button-warning-bg: #D69E2E;
--button-warning-bg-hover: #B7791F;
--button-warning-bg-disabled: rgba(214, 158, 46, 0.4);
--button-warning-text: white;
/* Button Sizes */
--button-sm-padding: 8px 12px;
--button-sm-padding: 6px 14px;
--button-sm-font-size: 12px;
--button-sm-icon-size: 14px;
--button-md-padding: 10px 20px;
--button-md-padding: 8px 18px;
--button-md-font-size: 14px;
--button-md-icon-size: 16px;
--button-lg-padding: 12px 24px;
--button-lg-font-size: 16px;
--button-lg-padding: 10px 22px;
--button-lg-font-size: 15px;
--button-lg-icon-size: 18px;
/* Button Border Radius */
--button-border-radius: 30px;
--button-border-radius: 6px;
/* Button Transitions */
--button-transition: all 0.2s ease;
--button-transition: all 0.15s ease;
}
/* Base Button Styles */
@ -66,17 +66,17 @@
}
.button:focus {
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.3);
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb, 74, 111, 165), 0.25);
}
.button:disabled {
cursor: not-allowed;
opacity: 0.6;
opacity: 0.5;
}
.button.loading {
cursor: not-allowed;
opacity: 0.7;
opacity: 0.65;
}
.button.loading .buttonIcon {
@ -104,7 +104,7 @@
}
.buttonSecondary:hover:not(:disabled) {
background: var(--color-secondary-hover);
background: var(--color-gray);
color: white;
}
@ -211,17 +211,17 @@
/* Responsive Design */
@media (max-width: 768px) {
.buttonSm {
padding: 4px 8px;
padding: 4px 10px;
font-size: 11px;
}
.buttonMd {
padding: 8px 16px;
padding: 6px 14px;
font-size: 13px;
}
.buttonLg {
padding: 10px 20px;
font-size: 15px;
padding: 8px 18px;
font-size: 14px;
}
}

View file

@ -52,10 +52,10 @@
.pageSubtitle {
font-size: 1rem;
font-weight: 600;
color: var(--color-secondary);
color: var(--color-text-secondary, #4A5568);
margin: 0.5rem 0 0 0;
font-family: var(--font-family);
letter-spacing: 0.5px;
letter-spacing: 0.3px;
display: flex;
align-items: center;
gap: 0.5rem;
@ -230,7 +230,7 @@
/* Horizontal divider lines */
.horizontalDivider {
width: calc(100% + 60px);
background-color: var(--color-primary);
background-color: var(--color-border, #E2E8F0);
height: 1px;
margin-left: -25px;
margin-bottom: 0;
@ -465,7 +465,7 @@
}
.chatHistoryNewChatButton {
border-radius: 25px !important;
border-radius: var(--button-border-radius, 6px) !important;
}
.chatHistoryEmpty {
@ -532,14 +532,14 @@
resize: none !important;
flex: 1;
box-sizing: border-box;
border-radius: 25px !important;
border: 1px solid var(--color-primary) !important;
border-radius: 8px !important;
border: 1px solid var(--color-border, #E2E8F0) !important;
box-shadow: none !important;
}
.textAreaFixed :global(textarea:focus) {
border: 1px solid var(--color-secondary) !important;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15) !important;
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb, 74, 111, 165), 0.15) !important;
outline: none !important;
}

View file

@ -1,47 +1,47 @@
:root {
/* Original color definitions */
--color-bg: #F8F9FA;
--color-surface: #EFEDE5;
--color-text: #3A3A3A;
--color-surface: #EEF0F2;
--color-text: #2D3748;
--color-primary: #C7C5B2;
--color-primary-hover: #D9D7C6;
--color-primary-disabled: #E3E2D8;
--color-primary: #4A6FA5;
--color-primary-hover: #3D5D8A;
--color-primary-disabled: rgba(74, 111, 165, 0.3);
--color-secondary: #F25843;
--color-secondary-hover: #FF6A55;
--color-secondary-disabled: #F5B0A4;
--color-secondary: #4A6FA5;
--color-secondary-hover: #3D5D8A;
--color-secondary-disabled: rgba(74, 111, 165, 0.35);
--color-secondary-rgb: 74, 111, 165;
--color-red: #dc3545;
--color-red-hover: #f5c6cb;
--color-red-disabled: #f8d7da;
--color-red: #C53030;
--color-red-hover: #E2B6B6;
--color-red-disabled: #F5D5D5;
--color-secondary-red: #B94A55;
--color-secondary-red-hover: #D46872;
--color-secondary-red-disabled: #E8B7BA;
--color-secondary-red: #C53030;
--color-secondary-red-hover: #9B2C2C;
--color-secondary-red-disabled: #E2B6B6;
--color-gray: #6F7373;
--color-gray-hover: #565A5A;
--color-gray-disabled: #B7BBBA;
--color-gray: #718096;
--color-gray-hover: #4A5568;
--color-gray-disabled: #CBD5E0;
--color-medium-gray: #E0DDD3;
--color-medium-gray-hover: #D1CEC5;
--color-medium-gray-disabled: #E0DDD380;
--color-medium-gray: #E2E8F0;
--color-medium-gray-hover: #CBD5E0;
--color-medium-gray-disabled: rgba(226, 232, 240, 0.5);
--color-highlight-gray: #F5F3ED;
--color-highlight-gray-hover: #E6E3DC;
--color-highlight-gray-disabled: #F5F3ED80;
--color-highlight-gray: #F7FAFC;
--color-highlight-gray-hover: #EDF2F7;
--color-highlight-gray-disabled: rgba(247, 250, 252, 0.5);
--color-success: #10b981;
--color-success-hover: #059669;
--color-success-disabled: #a7f3d0;
--color-success: #38A169;
--color-success-hover: #2F855A;
--color-success-disabled: #C6F6D5;
--color-text-primary: #3A3A3A; /*to be deleted*/
--color-text-primary: #2D3748;
--font-family: "DM Sans", sans-serif;
--object-radius-large: 30px;
--object-radius-medium: 15px;
--object-radius-small: 5px;
--object-radius-large: 10px;
--object-radius-medium: 8px;
--object-radius-small: 4px;
}
/* ============================================== */
@ -50,40 +50,40 @@
:root, .light-theme {
/* Background colors */
--bg-primary: #ffffff;
--bg-secondary: #F8F9FA;
--bg-dark: #f5f5f5;
--bg-secondary: #F7FAFC;
--bg-dark: #EDF2F7;
/* Surface colors */
--surface-color: #f8f9fa;
--surface-dark: #f0f0f0;
--surface-color: #F7FAFC;
--surface-dark: #EDF2F7;
/* Text colors */
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-tertiary: #888888;
--text-primary-dark: #1a1a1a;
--text-secondary-dark: #666666;
--text-tertiary-dark: #888888;
--text-primary: #1A202C;
--text-secondary: #4A5568;
--text-tertiary: #718096;
--text-primary-dark: #1A202C;
--text-secondary-dark: #4A5568;
--text-tertiary-dark: #718096;
/* Border colors */
--border-color: #e0e0e0;
--border-dark: #d0d0d0;
--border-color: #E2E8F0;
--border-dark: #CBD5E0;
/* Primary accent color */
--primary-color: #F25843;
--primary-color-dark: #D94A37;
--primary-color-light: rgba(242, 88, 67, 0.2);
--primary-light: rgba(242, 88, 67, 0.12);
--primary-dark-bg: rgba(242, 88, 67, 0.08);
--primary-color: #4A6FA5;
--primary-color-dark: #3D5D8A;
--primary-color-light: rgba(74, 111, 165, 0.15);
--primary-light: rgba(74, 111, 165, 0.1);
--primary-dark-bg: rgba(74, 111, 165, 0.06);
/* Hover backgrounds */
--hover-bg: rgba(0, 0, 0, 0.04);
--hover-bg-dark: rgba(0, 0, 0, 0.06);
--hover-bg: rgba(0, 0, 0, 0.03);
--hover-bg-dark: rgba(0, 0, 0, 0.05);
/* Error color */
--error-color: #dc2626;
--error-color: #C53030;
/* Legacy / inline-style aliases (override :root beige --color-primary) */
/* Legacy aliases */
--color-primary: var(--primary-color);
--color-primary-hover: var(--primary-color-dark);
--color-primary-disabled: var(--primary-color-light);
@ -97,68 +97,68 @@
/* DARK THEME */
/* ============================================== */
.dark-theme {
--color-bg: #181818;
--color-surface: #1E1D1A;
--color-text: #E5E7EB;
--color-bg: #1A202C;
--color-surface: #2D3748;
--color-text: #E2E8F0;
--color-primary: var(--primary-color);
--color-primary-hover: var(--primary-color-dark);
--color-primary-disabled: rgba(242, 88, 67, 0.35);
--color-primary-disabled: rgba(74, 111, 165, 0.35);
--color-secondary: #F25843;
--color-secondary-hover: #FF715C;
--color-secondary-disabled: #6E3E36;
--color-secondary: #5A8AC5;
--color-secondary-hover: #4A7AB5;
--color-secondary-disabled: #2C4A6E;
--color-secondary-rgb: 90, 138, 197;
--color-red: #dc3545;
--color-red-hover: #f5c6cb;
--color-red-disabled: #f8d7da;
--color-red: #FC8181;
--color-red-hover: #FEB2B2;
--color-red-disabled: #742A2A;
--color-secondary-red: #D65D6A;
--color-secondary-red-hover: #E17683;
--color-secondary-red-disabled: #70363C;
--color-secondary-red: #FC8181;
--color-secondary-red-hover: #FEB2B2;
--color-secondary-red-disabled: #742A2A;
/* Readable neutrals on dark (was #181818 — same as bg, illegible) */
--color-gray: #9ca3af;
--color-gray-hover: #d1d5db;
--color-gray-disabled: #57534e;
--color-gray: #A0AEC0;
--color-gray-hover: #CBD5E0;
--color-gray-disabled: #4A5568;
/* Background colors */
--bg-primary: #181818;
--bg-secondary: #1E1D1A;
--bg-dark: #0a0a0a;
--bg-primary: #1A202C;
--bg-secondary: #2D3748;
--bg-dark: #171923;
/* Surface colors */
--surface-color: #1E1D1A;
--surface-dark: #1a1a1a;
--surface-color: #2D3748;
--surface-dark: #1A202C;
/* Text colors */
--text-primary: #E5E7EB;
--text-secondary: #C7C5B2;
--text-tertiary: #9CA3AF;
--text-primary-dark: #E5E7EB;
--text-secondary-dark: #C7C5B2;
--text-tertiary-dark: #9CA3AF;
--text-primary: #E2E8F0;
--text-secondary: #A0AEC0;
--text-tertiary: #718096;
--text-primary-dark: #E2E8F0;
--text-secondary-dark: #A0AEC0;
--text-tertiary-dark: #718096;
/* Border colors */
--border-color: rgba(199, 197, 178, 0.15);
--border-dark: rgba(199, 197, 178, 0.15);
--border-color: rgba(226, 232, 240, 0.12);
--border-dark: rgba(226, 232, 240, 0.12);
/* Primary accent color */
--primary-color: #F25843;
--primary-color-dark: #D94A37;
--primary-color-light: rgba(242, 88, 67, 0.3);
--primary-light: #FF9A8A; /* Lighter red for text on dark backgrounds */
--primary-dark-bg: rgba(242, 88, 67, 0.15); /* Semi-transparent red for backgrounds */
--primary-color: #5A8AC5;
--primary-color-dark: #4A7AB5;
--primary-color-light: rgba(90, 138, 197, 0.3);
--primary-light: #7BA7D7;
--primary-dark-bg: rgba(90, 138, 197, 0.12);
/* Hover backgrounds */
--hover-bg: rgba(255, 255, 255, 0.06);
--hover-bg-dark: rgba(255, 255, 255, 0.06);
--hover-bg: rgba(255, 255, 255, 0.05);
--hover-bg-dark: rgba(255, 255, 255, 0.05);
/* Error color */
--error-color: #ef4444;
--error-color: #FC8181;
--color-border: var(--border-color);
--bg-card: #252422;
--bg-input: #2c2926;
--bg-hover: rgba(255, 255, 255, 0.08);
--bg-card: #2D3748;
--bg-input: #2D3748;
--bg-hover: rgba(255, 255, 255, 0.06);
}

View file

@ -259,7 +259,9 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
views: [
{ code: 'editor', label: { de: 'Editor', en: 'Editor' }, path: 'editor' },
{ code: 'workflows', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
{ code: 'templates', label: { de: 'Vorlagen', en: 'Templates' }, path: 'templates' },
{ code: 'workflows-tasks', label: { de: 'Tasks', en: 'Tasks' }, path: 'workflows-tasks' },
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard' }, path: 'dashboard' },
]
},
neutralization: {