From cd14babb2e37b1424aafa3d06145edc4bf085f41 Mon Sep 17 00:00:00 2001 From: ValueOn AG
+ * GET /api/workflow-automation/options/feature.instance?featureCode=
*
* Behavior matches the rest of the editor:
* - 0 results -> hint to create a feature instance for this mandate
@@ -42,7 +42,7 @@ export const FeatureInstancePicker: React.FC = ({
setLoading(true);
setLoadError(null);
request({
- url: `/api/workflows/${instanceId}/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`,
+ url: `/api/workflow-automation/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`,
method: 'get',
})
.then((res: unknown) => {
diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
index a2d9193..fa5f37e 100644
--- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
+++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
@@ -310,7 +310,7 @@ const ConnectionPicker: React.FC = ({ param, value, onChange
if (!instanceId || !request) return;
const qs = authority ? `?authority=${encodeURIComponent(authority)}` : '';
setLoadError(null);
- request({ url: `/api/workflows/${instanceId}/options/user.connection${qs}`, method: 'get' })
+ request({ url: `/api/workflow-automation/options/user.connection${qs}`, method: 'get' })
.then((res: unknown) => {
const data = res as { options?: Array<{ value: string; label: string }> };
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
@@ -328,7 +328,7 @@ const ConnectionPicker: React.FC = ({ param, value, onChange
return;
}
const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections);
- postUpstreamPaths(request, instanceId, graph, dataFlow.currentNodeId)
+ postUpstreamPaths(request, graph, dataFlow.currentNodeId)
.then(({ paths }) => {
const opts = paths
.filter(
diff --git a/src/components/FlowEditor/nodes/shared/DataPicker.tsx b/src/components/FlowEditor/nodes/shared/DataPicker.tsx
index 9cc9fb9..d1a289b 100644
--- a/src/components/FlowEditor/nodes/shared/DataPicker.tsx
+++ b/src/components/FlowEditor/nodes/shared/DataPicker.tsx
@@ -303,7 +303,7 @@ export const DataPicker: React.FC = ({ open,
if (scopeFetchKey.current === key) return; // already fetched for this state
scopeFetchKey.current = key;
const nodeShapes = (ctx.nodes ?? []).map((n) => ({ id: n.id, type: n.type }));
- fetchGraphDataSources(ctx.request, ctx.instanceId, ctx.currentNodeId, nodeShapes, connections)
+ fetchGraphDataSources(ctx.request, ctx.currentNodeId, nodeShapes, connections)
.then(setScopeData)
.catch(() => setScopeData(null));
}, [open, ctx?.instanceId, ctx?.request, ctx?.currentNodeId, connections, nodesRaw]);
diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx
index 87d7675..6086199 100644
--- a/src/components/UnifiedDataBar/FilesTab.tsx
+++ b/src/components/UnifiedDataBar/FilesTab.tsx
@@ -1,12 +1,6 @@
import React, { useCallback, useRef, useMemo, useState, useEffect } from 'react';
import type { UdbContext } from './UnifiedDataBar';
import api from '../../api';
-import { useApiRequest } from '../../hooks/useApi';
-import {
- importWorkflowFromFile,
- WORKFLOW_FILE_EXTENSION,
-} from '../../api/workflowApi';
-import { useToast } from '../../contexts/ToastContext';
import { FormGeneratorTree } from '../FormGenerator/FormGeneratorTree';
import { createFolderFileProvider } from '../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
import type { TreeNode } from '../FormGenerator/FormGeneratorTree';
@@ -17,15 +11,10 @@ interface FilesTabProps {
context: UdbContext;
onFileSelect?: (fileId: string, fileName?: string) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'group'; name: string }>) => void;
- /** Wird aufgerufen, wenn ein ``.workflow.json``-File via Custom-Action in
- * den Graph-Editor importiert wurde. */
- onWorkflowImported?: (workflowId: string) => void;
}
-const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat, onWorkflowImported }) => {
+const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat }) => {
const { t } = useLanguage();
- const { request } = useApiRequest();
- const { showSuccess, showError } = useToast();
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
@@ -126,33 +115,6 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat
}
}, [_uploadFiles]);
- /* Workflow import is only available when embedded in the graph editor */
- const _handleWorkflowImport = useCallback(async (fileId: string, fileName: string) => {
- if (context.surface !== 'graphEditor' || !context.instanceId) return;
- if (!fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) return;
- try {
- const result = await importWorkflowFromFile(request, context.instanceId, { fileId });
- const warnings = result?.warnings ?? [];
- const wfId = result?.workflow?.id;
- if (warnings.length > 0) {
- showSuccess(t('Workflow importiert ({n} Warnungen).', { n: String(warnings.length) }));
- } else {
- showSuccess(t('Workflow importiert (deaktiviert).'));
- }
- if (wfId && onWorkflowImported) onWorkflowImported(wfId);
- } catch (e: unknown) {
- const msg = e instanceof Error ? e.message : String(e);
- showError(t('Import fehlgeschlagen: {msg}', { msg }));
- }
- }, [context.surface, context.instanceId, request, showSuccess, showError, t, onWorkflowImported]);
-
- const _handleNodeClickWithImport = useCallback((node: TreeNode) => {
- _handleNodeClick(node);
- if (node.type === 'file') {
- _handleWorkflowImport(node.id, node.name);
- }
- }, [_handleNodeClick, _handleWorkflowImport]);
-
const _handleSendToChat = useCallback((node: TreeNode) => {
onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
}, [onSendToChat]);
@@ -243,7 +205,7 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat
title={t('Eigene')}
compact={true}
showFilter={true}
- onNodeClick={_handleNodeClickWithImport}
+ onNodeClick={_handleNodeClick}
onSendToChat={_handleSendToChat}
/>
= ({ context, onFileSelect, onSendToChat
collapsible={true}
defaultCollapsed={true}
emptyMessage={t('Keine geteilten Dateien')}
- onNodeClick={_handleNodeClickWithImport}
+ onNodeClick={_handleNodeClick}
onSendToChat={_handleSendToChat}
/>
diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx
index 2366c2c..ca6ebc2 100644
--- a/src/components/UnifiedDataBar/UnifiedDataBar.tsx
+++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx
@@ -8,14 +8,10 @@ import styles from './UnifiedDataBar.module.css';
export type UdbTab = 'chats' | 'files' | 'sources';
/** Aufruf-Surface, in der die UDB gerade lebt. Wird an Custom-Actions
- * (z. B. `workflow.openInEditor`) weitergereicht, damit sie sich
- * pro Surface registrieren können. */
-export type UdbSurface =
- | 'workspace'
- | 'graphEditor'
- | 'trustee'
- | 'standalone'
- | 'sharepoint';
+ * weitergereicht, damit sie sich pro Surface registrieren koennen.
+ * Bekannte Werte: 'workspace', 'workflowAutomation', 'trustee',
+ * 'standalone', 'sharepoint' — beliebig erweiterbar durch Consumer. */
+export type UdbSurface = string;
export interface UdbContext {
instanceId: string;
@@ -56,9 +52,6 @@ interface UnifiedDataBarProps {
onSendToChat_Files?: (items: AddToChat_FileItem[]) => void;
onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void;
onAttachDataSource?: (dsId: string) => void;
- /** Wird aufgerufen, sobald aus der UDB-FilesTab ein Workflow-File in den
- * Graph-Editor importiert wurde (Action `workflow.openInEditor`). */
- onWorkflowImportedFromFile?: (workflowId: string) => void;
className?: string;
}
@@ -87,7 +80,6 @@ const UnifiedDataBar: React.FC = ({
onSendToChat_Files,
onSendToChat_FeatureSource,
onAttachDataSource,
- onWorkflowImportedFromFile,
className,
}) => {
const { t } = useLanguage();
@@ -132,7 +124,6 @@ const UnifiedDataBar: React.FC = ({
context={context}
onFileSelect={onFileSelect}
onSendToChat={onSendToChat_Files}
- onWorkflowImported={onWorkflowImportedFromFile}
/>
)}
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
diff --git a/src/components/workflowAutomation/FlowEditor/index.ts b/src/components/workflowAutomation/FlowEditor/index.ts
new file mode 100644
index 0000000..7e02125
--- /dev/null
+++ b/src/components/workflowAutomation/FlowEditor/index.ts
@@ -0,0 +1,8 @@
+/**
+ * FlowEditor re-export shim.
+ *
+ * Allows gradual migration of imports to the workflowAutomation folder
+ * without breaking anything. All exports proxy through to ../../FlowEditor.
+ */
+
+export * from '../../FlowEditor';
diff --git a/src/config/keepAliveRoutes.tsx b/src/config/keepAliveRoutes.tsx
index 80a57c3..35f94f8 100644
--- a/src/config/keepAliveRoutes.tsx
+++ b/src/config/keepAliveRoutes.tsx
@@ -3,8 +3,14 @@ import { AdminDatabaseHealthPage } from '../pages/admin/AdminDatabaseHealthPage'
import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage';
import { CommcoachSessionView } from '../pages/views/commcoach';
import { WorkspacePage } from '../pages/views/workspace/WorkspacePage';
+import { WorkflowAutomationPage } from '../pages/workflowAutomation/WorkflowAutomationHubPage';
export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [
+ {
+ id: 'workflow-automation-editor',
+ pathRegex: /\/workflow-automation(?:\?.*tab=editor|$)/,
+ render: () => ,
+ },
{
id: 'workspace-dashboard',
pathRegex: /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/,
diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx
index 866d345..baa5d68 100644
--- a/src/config/pageRegistry.tsx
+++ b/src/config/pageRegistry.tsx
@@ -22,7 +22,7 @@ import {
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase,
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
- FaFileContract, FaRobot, FaGlobe, FaClipboardCheck,
+ FaFileContract, FaGlobe, FaClipboardCheck,
FaSitemap, FaCopy, FaTasks,
} from 'react-icons/fa';
@@ -53,7 +53,6 @@ export const PAGE_ICONS: Record = {
// System pages - Usage
'page.system.billingAdmin': ,
'page.system.statistics': ,
- 'page.system.automations': ,
'page.system.ragInventory': ,
// System pages - Workflow Automation
diff --git a/src/hooks/useIntegrationsOverview.ts b/src/hooks/useIntegrationsOverview.ts
index 0e07a22..0565d2e 100644
--- a/src/hooks/useIntegrationsOverview.ts
+++ b/src/hooks/useIntegrationsOverview.ts
@@ -100,11 +100,11 @@ function _dotColorForIndex(index: number): string {
return palette[index % palette.length];
}
-function _collectGraphicalEditorInstanceIds(mandates: NavigationMandate[]): string[] {
+function _collectWorkflowAutomationInstanceIds(mandates: NavigationMandate[]): string[] {
const ids: string[] = [];
for (const mandate of mandates) {
for (const feature of mandate.features) {
- if (feature.uiComponent === 'feature.graphicalEditor') {
+ if (feature.uiComponent === 'feature.workflowAutomation') {
for (const inst of feature.instances) {
if (inst.id && !ids.includes(inst.id)) {
ids.push(inst.id);
@@ -271,13 +271,13 @@ export function useIntegrationsOverview(): UseIntegrationsOverviewResult {
setError((prev) => (prev ? `${prev} | ${msg}` : msg));
}
- const geIds = _collectGraphicalEditorInstanceIds(mandatesForWorkflows);
+ const waIds = _collectWorkflowAutomationInstanceIds(mandatesForWorkflows);
const wfLabels: string[] = [];
const seenWf = new Set();
- for (const instanceId of geIds.slice(0, 4)) {
+ for (const instanceId of waIds.slice(0, 4)) {
try {
- const wfRes = await api.get(`/api/workflows/${instanceId}/workflows`, {
- params: { active: 'true' },
+ const wfRes = await api.get(`/api/workflow-automation/workflows`, {
+ params: { active: 'true', instanceId },
});
const wfData = wfRes.data;
const list = Array.isArray(wfData)
diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts
index 4723e70..62a41b2 100644
--- a/src/hooks/useWorkflows.ts
+++ b/src/hooks/useWorkflows.ts
@@ -7,25 +7,22 @@ import {
fetchWorkflow as fetchWorkflowFromApi,
deleteWorkflow as deleteWorkflowFromApi,
updateWorkflow as updateWorkflowFromApi,
-} from '../api/workflowApi';
+} from '../api/workflowAutomationApi';
import { useWorkflowSelection } from '../contexts/WorkflowSelectionContext';
import { usePermissions, type UserPermissions } from './usePermissions';
export type StartWorkflowRequest = Record;
-function _workflowsInstanceIdFromBaseUrl(apiBaseUrl: string | undefined): string | null {
- if (!apiBaseUrl) return null;
- const m = apiBaseUrl.match(/^\/api\/workflows\/([^/]+)$/);
- return m ? m[1] : null;
+function _isValidApiBaseUrl(apiBaseUrl: string | undefined): boolean {
+ return apiBaseUrl === '/api/workflow-automation';
}
async function _deleteWorkflowsSequential(
request: ApiRequestFunction,
- instanceId: string,
workflowIds: string[],
) {
for (const id of workflowIds) {
- await deleteWorkflowFromApi(request, instanceId, id);
+ await deleteWorkflowFromApi(request, id);
}
}
@@ -36,7 +33,7 @@ async function startWorkflowApi(
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' },
) {
return await request({
- url: `/api/workflows/${instanceId}/execute`,
+ url: `/api/workflow-automation/execute`,
method: 'post',
data: {
workflowId: options?.workflowId ?? (workflowData as { workflowId?: string }).workflowId,
@@ -105,10 +102,10 @@ export interface PaginationParams {
search?: string;
}
-/** Get apiBaseUrl from instanceId and featureCode for feature-scoped workflow APIs */
+/** Get apiBaseUrl for workflow APIs (mandate-scoped) */
export function getWorkflowApiBaseUrl(instanceId: string | undefined, featureCode: string | undefined): string | undefined {
- if (!instanceId || !featureCode) return undefined;
- if (featureCode === 'graphicalEditor') return `/api/workflows/${instanceId}`;
+ if (!featureCode) return undefined;
+ if (featureCode === 'workflowAutomation') return `/api/workflow-automation`;
return undefined;
}
@@ -162,13 +159,8 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
const fetchWorkflowsData = useCallback(async (params?: PaginationParams) => {
try {
- if (!apiBaseUrl) {
- console.error('useUserWorkflows: apiBaseUrl is required (missing instanceId/featureCode)');
- return;
- }
- const instanceId = _workflowsInstanceIdFromBaseUrl(apiBaseUrl);
- if (!instanceId) {
- console.error('useUserWorkflows: could not parse instanceId from apiBaseUrl');
+ if (!apiBaseUrl || !_isValidApiBaseUrl(apiBaseUrl)) {
+ console.error('useUserWorkflows: apiBaseUrl is required (missing featureCode)');
return;
}
let listParams: { pagination?: Record } | undefined = undefined;
@@ -183,7 +175,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
listParams = { pagination: paginationObj };
}
}
- const data: unknown = await fetchWorkflowsFromApi(request, instanceId, listParams);
+ const data: unknown = await fetchWorkflowsFromApi(request, listParams ? { pagination: listParams.pagination } : undefined);
// Handle paginated response
if (data && typeof data === 'object' && data !== null && 'items' in data) {
@@ -246,9 +238,8 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
// Fetch a single workflow by ID
const fetchWorkflowById = useCallback(async (workflowId: string): Promise => {
try {
- const instanceId = _workflowsInstanceIdFromBaseUrl(apiBaseUrl);
- if (!instanceId) return null;
- const workflow = await fetchWorkflowFromApi(request, instanceId, workflowId);
+ if (!_isValidApiBaseUrl(apiBaseUrl)) return null;
+ const workflow = await fetchWorkflowFromApi(request, workflowId);
return workflow as unknown as UserWorkflow | null;
} catch (error: any) {
console.error('Error fetching workflow by ID:', error);
@@ -526,8 +517,7 @@ export function useWorkflowOperations(options?: { instanceId?: string; featureCo
setDeletingWorkflows,
setDeleteError,
() => {
- if (!instanceId) throw new Error('instanceId required');
- return deleteWorkflowFromApi(request, instanceId, workflowId);
+ return deleteWorkflowFromApi(request, workflowId);
},
{
default: 'Failed to delete workflow',
@@ -563,8 +553,7 @@ export function useWorkflowOperations(options?: { instanceId?: string; featureCo
});
try {
- if (!instanceId) throw new Error('instanceId required');
- await _deleteWorkflowsSequential(request, instanceId, workflowIds);
+ await _deleteWorkflowsSequential(request, workflowIds);
// Add a small delay to ensure backend has time to process
await new Promise(resolve => setTimeout(resolve, 300));
@@ -638,8 +627,7 @@ export function useWorkflowOperations(options?: { instanceId?: string; featureCo
setEditingWorkflows(prev => new Set(prev).add(workflowId));
try {
- if (!instanceId) throw new Error('instanceId required');
- const updatedWorkflow = await updateWorkflowFromApi(request, instanceId, workflowId, {
+ const updatedWorkflow = await updateWorkflowFromApi(request, workflowId, {
label: updateData.name,
});
diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx
deleted file mode 100644
index 1a60022..0000000
--- a/src/pages/AutomationsDashboardPage.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * AutomationsDashboardPage
- *
- * Legacy wrapper — redirects to /workflow-automation.
- * The full automation hub now lives in WorkflowAutomationPage.
- */
-
-import React from 'react';
-import { Navigate, useSearchParams } from 'react-router-dom';
-
-export const AutomationsDashboardPage: React.FC = () => {
- const [searchParams] = useSearchParams();
- const tab = searchParams.get('tab');
- const runId = searchParams.get('runId');
- const params = new URLSearchParams();
- if (tab) params.set('tab', tab);
- if (runId) params.set('runId', runId);
- const qs = params.toString();
- return ;
-};
-
-export default AutomationsDashboardPage;
diff --git a/src/pages/WorkflowAutomationPage.tsx b/src/pages/WorkflowAutomationPage.tsx
deleted file mode 100644
index 00a4a0a..0000000
--- a/src/pages/WorkflowAutomationPage.tsx
+++ /dev/null
@@ -1,1565 +0,0 @@
-/**
- * WorkflowAutomationPage
- *
- * System-level hub for WorkflowAutomation (mandatsweite Sicht).
- * Tabs: Workflows · Editor · Vorlagen · Läufe · Details
- *
- * Replaces the former AutomationsDashboardPage at /automations.
- * Uses /api/system/workflow-runs/* endpoints (proven, RBAC-filtered).
- * Editor + Templates tabs embed the existing graphicalEditor components.
- */
-
-import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
-import { useNavigate, useSearchParams } from 'react-router-dom';
-import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload, FaCheck, FaBan, FaPen, FaEye, FaTimes, FaStream, FaStop } from 'react-icons/fa';
-import { FormGeneratorTable, type ColumnConfig } from '../components/FormGenerator';
-import { Tabs } from '../components/UiComponents/Tabs';
-import { useToast } from '../contexts/ToastContext';
-import { usePrompt } from '../hooks/usePrompt';
-import { useApiRequest } from '../hooks/useApi';
-import { formatUnixTimestamp } from '../utils/time';
-import { updateWorkflow, executeGraph, deleteSystemWorkflow, fetchWorkspaceRunDetail } from '../api/workflowApi';
-import { fetchAttributes } from '../api/attributesApi';
-import type { AttributeDefinition } from '../api/attributesApi';
-import { resolveColumnTypes } from '../utils/columnTypeResolver';
-import api from '../api';
-import { useLanguage } from '../providers/language/LanguageContext';
-import { useNavigation, type DynamicBlock } from '../hooks/useNavigation';
-import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage';
-import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage';
-import styles from './admin/Admin.module.css';
-
-// ---------------------------------------------------------------------------
-// Shared types & helpers
-// ---------------------------------------------------------------------------
-
-interface WorkflowRunMetrics {
- totalRuns: number;
- runsByStatus: Record;
- totalTokens: number;
- totalCredits: number;
- workflowCount: number;
- activeWorkflows: number;
-}
-
-interface WorkflowRun {
- id: string;
- workflowId: string;
- workflowLabel?: string;
- mandateId?: string;
- mandateLabel?: string;
- featureInstanceId?: string;
- instanceLabel?: string;
- ownerId?: string;
- ownerLabel?: string;
- status: string;
- costTokens?: number;
- costCredits?: number;
- sysCreatedAt?: number;
- sysModifiedAt?: number;
-}
-
-interface SystemWorkflow {
- id: string;
- mandateId: string;
- featureInstanceId: string;
- featureCode?: string;
- label: string;
- active: boolean;
- isRunning?: boolean;
- activeRunId?: string;
- stuckAtNodeLabel?: string;
- stuckAtNodeId?: string;
- createdAt?: number;
- sysCreatedAt?: number;
- lastStartedAt?: number;
- runCount?: number;
- mandateLabel?: string;
- instanceLabel?: string;
- ownerId?: string;
- ownerLabel?: string;
- canEdit?: boolean;
- canDelete?: boolean;
- canExecute?: boolean;
- invocations?: Array<{ id: string; enabled: boolean; kind: string }>;
- graph?: Record;
-}
-
-const _FEATURES_WITH_EDITOR = new Set(['graphicalEditor', 'workspace']);
-
-const _ROLE_PRIORITY: Record = { admin: 3, user: 2, viewer: 1 };
-
-function _bestEditorInstance(
- dynamicBlock: DynamicBlock | null,
- mandateId: string,
-): { instanceId: string; featureCode: string } | null {
- if (!dynamicBlock) return null;
- const mandate = dynamicBlock.mandates.find((m) => m.id === mandateId);
- if (!mandate) return null;
-
- let best: { instanceId: string; featureCode: string; score: number } | null = null;
- for (const feat of mandate.features) {
- for (const inst of feat.instances) {
- const fc = inst.featureCode
- || feat.uiComponent.replace(/^feature\./, '');
- if (!_FEATURES_WITH_EDITOR.has(fc)) continue;
- let score = 0;
- if (inst.isAdmin) {
- score = 10;
- } else {
- for (const v of inst.views) {
- const key = v.objectKey || '';
- for (const [suffix, prio] of Object.entries(_ROLE_PRIORITY)) {
- if (key.endsWith(suffix) && prio > score) score = prio;
- }
- }
- }
- if (!best || score > best.score) {
- best = { instanceId: inst.id, featureCode: fc, score };
- }
- }
- }
- return best ? { instanceId: best.instanceId, featureCode: best.featureCode } : null;
-}
-
-/** Find the first available editor instance across all mandates. */
-function _findAnyEditorInstance(
- dynamicBlock: DynamicBlock | null,
-): { instanceId: string; mandateId: string; featureCode: string } | null {
- if (!dynamicBlock) return null;
- for (const mandate of dynamicBlock.mandates) {
- const result = _bestEditorInstance(dynamicBlock, mandate.id);
- if (result) return { ...result, mandateId: mandate.id };
- }
- return null;
-}
-
-function _formatTs(ts?: number): string {
- if (ts == null || ts <= 0) return '—';
- const sec = ts < 1e12 ? ts : ts / 1000;
- const { time } = formatUnixTimestamp(sec, undefined, {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- });
- return time;
-}
-
-const _STATUS_COLORS: Record = {
- completed: 'var(--success-color, #28a745)',
- failed: 'var(--danger-color, #dc3545)',
- running: 'var(--primary-color, #007bff)',
- paused: 'var(--warning-color, #ffc107)',
- stopped: 'var(--warning-color, #ffc107)',
- cancelled: 'var(--text-secondary, #666)',
-};
-
-// ---------------------------------------------------------------------------
-// MetricCard
-// ---------------------------------------------------------------------------
-
-interface MetricCardProps {
- icon: React.ReactNode;
- label: string;
- value: string | number;
- color?: string;
-}
-
-const MetricCard: React.FC = ({ icon, label, value, color }) => (
-
-
- {icon}
-
-
- {label}
- {value}
-
-
-);
-
-// ===========================================================================
-// Live Run Tracing Modal (SSE-based, can be opened/closed freely)
-// ===========================================================================
-
-interface _TracingStep {
- id: string;
- nodeId: string;
- nodeType: string;
- status: string;
- startedAt?: number;
- completedAt?: number;
- durationMs?: number;
- error?: string;
- tokensUsed?: number;
- inputSnapshot?: Record;
- output?: Record;
- retryCount?: number;
-}
-
-const _STATUS_ICONS: Record = {
- pending: '○', running: '◉', completed: '✓', failed: '✗', stopped: '■', skipped: '—',
-};
-
-function _formatStepTs(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 (
-
-
- {open && (
-
- {content}
-
- )}
-
- );
-};
-
-interface _RunTracingModalProps {
- run: WorkflowRun;
- onClose: () => void;
-}
-
-const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) => {
- const { t } = useLanguage();
- const [steps, setSteps] = useState<_TracingStep[]>([]);
- const [loading, setLoading] = useState(false);
- const [sseConnected, setSseConnected] = useState(false);
- const eventSourceRef = useRef(null);
- const scrollRef = useRef(null);
-
- const _loadSteps = useCallback(async () => {
- setLoading(true);
- try {
- const resp = await api.get(`/api/system/workflow-runs/${run.id}/steps`);
- setSteps(resp.data?.steps || []);
- } catch (e) {
- console.error('[RunTracing] Failed to load steps:', e);
- } finally {
- setLoading(false);
- }
- }, [run.id]);
-
- const isRunning = run.status === 'running' || run.status === 'paused';
-
- useEffect(() => {
- _loadSteps();
-
- if (!isRunning) return;
-
- const baseUrl = api.defaults.baseURL || '';
- const url = `${baseUrl}/api/system/workflow-runs/${run.id}/stream`;
- const es = new EventSource(url, { withCredentials: true });
- 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 _TracingStep];
- });
- } 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);
- };
- }, [run.id, run.status]); // eslint-disable-line react-hooks/exhaustive-deps
-
- useEffect(() => {
- if (!isRunning) return;
- const interval = setInterval(() => { _loadSteps(); }, 5000);
- return () => clearInterval(interval);
- }, [isRunning, _loadSteps]);
-
- useEffect(() => {
- if (scrollRef.current) {
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
- }
- }, [steps]);
-
- return (
-
-
-
-
-
- {t('Run-Tracing')}: {run.workflowLabel || run.workflowId}
-
-
-
- {run.status}
-
- {sseConnected && (
- ● {t('Live')}
- )}
-
-
-
-
-
- {loading && steps.length === 0 && (
- {t('Wird geladen…')}
- )}
- {!loading && steps.length === 0 && (
- {t('Noch keine Schritte aufgezeichnet')}
- )}
- {steps.map((step) => {
- const startStr = _formatStepTs(step.startedAt);
- const endStr = _formatStepTs(step.completedAt);
- const inputStr = _truncateJson(step.inputSnapshot);
- const outputStr = _truncateJson(step.output);
- const isLoop = step.inputSnapshot?._loopIndex != null;
-
- return (
-
-
-
-
- {_STATUS_ICONS[step.status] || '?'}
-
- {step.nodeType}
- ({step.nodeId})
- {isLoop && (
-
- [iter {step.inputSnapshot!._loopIndex}]
-
- )}
-
-
- {(step.retryCount ?? 0) > 0 && (
-
- {step.retryCount}x {t('Wiederholung')}
-
- )}
- {step.durationMs != null && (
- {step.durationMs}ms
- )}
-
-
- {(startStr || endStr) && (
-
- {startStr && {startStr}}
- {startStr && endStr && → }
- {endStr && {endStr}}
-
- )}
- {step.error && (
- {step.error}
- )}
- {(step.tokensUsed ?? 0) > 0 && (
-
- {step.tokensUsed} {t('Tokens')}
-
- )}
- <_CollapsibleSection label={t('Eingabe')} content={inputStr} />
- <_CollapsibleSection label={t('Ausgabe')} content={outputStr} />
-
- );
- })}
-
-
-
- );
-};
-
-// ===========================================================================
-// DashboardTab — Metrics + Runs table with backend pagination
-// ===========================================================================
-
-interface _DashboardTabProps {
- workflowFilter?: string | null;
- onRunClick?: (runId: string) => void;
-}
-
-const _DashboardTab: React.FC<_DashboardTabProps> = ({ workflowFilter, onRunClick }) => {
- const { t } = useLanguage();
- const { request } = useApiRequest();
- const { showError } = useToast();
-
- const [metrics, setMetrics] = useState(null);
- const [runs, setRuns] = useState([]);
- const [loading, setLoading] = useState(true);
- const [paginationMeta, setPaginationMeta] = useState(null);
- const [tracingRun, setTracingRun] = useState(null);
- const lastPaginationParamsRef = useRef(null);
- const [backendAttributes, setBackendAttributes] = useState([]);
-
- useEffect(() => {
- fetchAttributes(request, 'AutoRun')
- .then(setBackendAttributes)
- .catch((err) => { console.error('[automations] fetchAttributes AutoRun failed', err); });
- }, [request]);
-
- const _loadMetrics = useCallback(async () => {
- try {
- const resp = await api.get('/api/system/workflow-runs/metrics');
- setMetrics(resp.data);
- } catch (e: any) {
- const msg = e?.response?.data?.detail || e?.message || String(e);
- console.error('[automations] metrics load failed', e);
- showError(t('Metriken konnten nicht geladen werden: {msg}', { msg }));
- }
- }, [showError, t]);
-
- const _loadRuns = useCallback(async (paginationParams?: any) => {
- if (paginationParams !== undefined) {
- lastPaginationParamsRef.current = paginationParams;
- }
- const effectiveParams = paginationParams ?? lastPaginationParamsRef.current;
- setLoading(true);
- try {
- const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }];
- const pag = {
- page: effectiveParams?.page || 1,
- pageSize: effectiveParams?.pageSize || 25,
- sort: effectiveParams?.sort || defaultSort,
- ...(effectiveParams?.search ? { search: effectiveParams.search } : {}),
- ...(effectiveParams?.filters ? { filters: effectiveParams.filters } : {}),
- };
- const params: Record = { pagination: JSON.stringify(pag) };
- const resp = await api.get('/api/system/workflow-runs', { params });
- const data = resp.data;
- setRuns(data?.runs || []);
- const total = data?.total ?? 0;
- const pageSize = pag.pageSize;
- setPaginationMeta({
- currentPage: pag.page,
- pageSize,
- totalItems: total,
- totalPages: Math.ceil(total / pageSize),
- });
- } catch (e) {
- console.error('[automations] runs load failed', e);
- showError(t('Fehler beim Laden der Workflow-Runs'));
- } finally {
- setLoading(false);
- }
- }, [showError, t]);
-
- useEffect(() => {
- _loadMetrics();
- }, [_loadMetrics]);
-
- const hasRunningRuns = runs.some((r) => r.status === 'running' || r.status === 'paused');
- useEffect(() => {
- if (!hasRunningRuns) return;
- const interval = setInterval(() => {
- _loadRuns();
- _loadMetrics();
- }, 5000);
- return () => clearInterval(interval);
- }, [hasRunningRuns, _loadRuns, _loadMetrics]);
-
- const _downloadRunTracing = useCallback(async (run: WorkflowRun) => {
- if (!run.id) return;
- try {
- const resp = await api.get(`/api/system/workflow-runs/${run.id}/steps`);
- const steps = resp.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('[automations] download tracing failed', e);
- showError(t('Download fehlgeschlagen'));
- }
- }, [showError, t]);
-
- const _initialFilters = useMemo(() => {
- if (!workflowFilter) return undefined;
- return { workflowId: workflowFilter };
- }, [workflowFilter]);
-
- const _rawRunColumns: ColumnConfig[] = useMemo(() => [
- {
- key: 'workflowId',
- label: t('Workflow'),
- width: 200,
- sortable: true,
- filterable: true,
- displayField: 'workflowLabel',
- },
- {
- key: 'mandateId',
- label: t('Mandant'),
- width: 140,
- sortable: true,
- filterable: true,
- displayField: 'mandateLabel',
- },
- {
- key: 'featureInstanceId',
- label: t('Instanz'),
- width: 140,
- sortable: true,
- filterable: true,
- displayField: 'instanceLabel',
- },
- {
- key: 'ownerId',
- label: t('Benutzer'),
- width: 140,
- sortable: true,
- filterable: true,
- displayField: 'ownerLabel',
- },
- { key: 'status', width: 110, sortable: true, filterable: true },
- {
- key: 'startedAt',
- label: t('Gestartet'),
- width: 150,
- sortable: true,
- filterable: true,
- formatter: (v: number) => _formatTs(v),
- },
- {
- key: 'completedAt',
- label: t('Beendet'),
- width: 150,
- sortable: true,
- filterable: true,
- formatter: (v: number) => _formatTs(v),
- },
- ], [t]);
-
- const _runColumns = useMemo(
- () => resolveColumnTypes(_rawRunColumns, backendAttributes),
- [_rawRunColumns, backendAttributes],
- );
-
- const _hookData = useMemo(() => ({
- refetch: _loadRuns,
- pagination: paginationMeta,
- }), [_loadRuns, paginationMeta]);
-
- return (
- <>
-
-
- {t('Workflow-Runs über alle Features und Mandanten')}
-
-
-
-
-
-
-
- } label={t('Workflows')} value={metrics?.workflowCount ?? t('—')} />
- } label={t('Aktive Workflows')} value={metrics?.activeWorkflows ?? t('—')} color="var(--success-color, #28a745)" />
- } label={t('Runs gesamt')} value={metrics?.totalRuns ?? t('—')} />
-
-
- {metrics?.runsByStatus && Object.keys(metrics.runsByStatus).length > 0 && (
-
- {t('Läufe nach Status')}
-
- {Object.entries(metrics.runsByStatus).map(([status, count]) => (
-
- {status}: {count}
-
- ))}
-
-
- )}
-
- {metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && (
-
- {metrics.totalTokens > 0 && (
-
- {t('Tokens gesamt:')}
- {metrics.totalTokens.toLocaleString('de-DE')}
-
- )}
- {metrics.totalCredits > 0 && (
-
- {t('Credits gesamt:')}
- {metrics.totalCredits.toLocaleString('de-DE', { minimumFractionDigits: 2 })}
-
- )}
-
- )}
-
-
- {t('Letzte Runs')}
-
-
-
- data={runs}
- columns={_runColumns}
- loading={loading}
- pagination={true}
- pageSize={25}
- searchable={true}
- filterable={true}
- sortable={true}
- selectable={true}
- initialSort={[{ key: 'startedAt', direction: 'desc' }]}
- initialFilters={_initialFilters}
- apiEndpoint="/api/system/workflow-runs"
- onRowClick={(row) => onRunClick?.(row.id)}
- customActions={[
- {
- id: 'tracing',
- icon: ,
- title: t('Run-Tracing anzeigen'),
- onClick: (row) => setTracingRun(row),
- },
- {
- id: 'download',
- icon: ,
- title: t('Tracing-Protokoll herunterladen'),
- onClick: (row) => _downloadRunTracing(row),
- },
- ]}
- hookData={_hookData}
- emptyMessage={t('Noch keine Workflow-Runs vorhanden.')}
- />
-
- {tracingRun && (
- <_RunTracingModal run={tracingRun} onClose={() => setTracingRun(null)} />
- )}
- >
- );
-};
-
-// ===========================================================================
-// WorkflowsTab — Central workflow management across all instances
-// ===========================================================================
-
-interface _WorkflowsTabProps {
- onWorkflowClick?: (workflowId: string) => void;
-}
-
-const _WorkflowsTab: React.FC<_WorkflowsTabProps> = ({ onWorkflowClick }) => {
- const { t } = useLanguage();
- const navigate = useNavigate();
- const { request } = useApiRequest();
- const { showSuccess, showError } = useToast();
- const { prompt: promptInput, PromptDialog } = usePrompt();
- const { dynamicBlock } = useNavigation();
-
- const [workflows, setWorkflows] = useState([]);
- const [loading, setLoading] = useState(true);
- const [executingId, setExecutingId] = useState(null);
- const [togglingId, setTogglingId] = useState(null);
- const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
- const [paginationMeta, setPaginationMeta] = useState(null);
- const lastPaginationParamsRef = useRef(null);
- const [backendAttributes, setBackendAttributes] = useState([]);
-
- useEffect(() => {
- fetchAttributes(request, 'Automation2WorkflowView')
- .then(setBackendAttributes)
- .catch((err) => { console.error('[automations] fetchAttributes Automation2WorkflowView failed', err); });
- }, [request]);
-
- const _load = useCallback(async (paginationParams?: any) => {
- if (paginationParams !== undefined) {
- lastPaginationParamsRef.current = paginationParams;
- }
- const effectiveParams = paginationParams ?? lastPaginationParamsRef.current;
- setLoading(true);
- try {
- const params: Record = {};
- if (activeFilter === 'active') params.active = true;
- if (activeFilter === 'inactive') params.active = false;
-
- const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }];
- const pag = {
- page: effectiveParams?.page || 1,
- pageSize: effectiveParams?.pageSize || 25,
- sort: effectiveParams?.sort || defaultSort,
- ...(effectiveParams?.search ? { search: effectiveParams.search } : {}),
- ...(effectiveParams?.filters ? { filters: effectiveParams.filters } : {}),
- };
- params.pagination = JSON.stringify(pag);
-
- const resp = await api.get('/api/system/workflow-runs/workflows', { params });
- const data = resp.data;
- setWorkflows(data?.items || []);
- setPaginationMeta(data?.pagination || null);
- } catch (e) {
- console.error('[automations] load system workflows failed', e);
- showError(t('Fehler beim Laden der Workflows'));
- } finally {
- setLoading(false);
- }
- }, [activeFilter, showError, t]);
-
- useEffect(() => {
- _load();
- }, [_load]);
-
- const hasRunningWorkflows = workflows.some((w) => w.isRunning);
- useEffect(() => {
- if (!hasRunningWorkflows) return;
- const interval = setInterval(() => { _load(); }, 5000);
- return () => clearInterval(interval);
- }, [hasRunningWorkflows, _load]);
-
- const _handleEdit = useCallback((row: SystemWorkflow) => {
- if (!row.mandateId) return;
- const fc = row.featureCode || '';
- if (_FEATURES_WITH_EDITOR.has(fc)) {
- navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`);
- return;
- }
- const editor = _bestEditorInstance(dynamicBlock, row.mandateId);
- if (!editor) {
- showError(t('Kein Editor verfügbar für diesen Mandanten'));
- return;
- }
- navigate(`/mandates/${row.mandateId}/${editor.featureCode}/${editor.instanceId}/editor?workflowId=${row.id}`);
- }, [navigate, showError, t, dynamicBlock]);
-
- const _handleDelete = useCallback(async (workflowId: string): Promise => {
- try {
- await deleteSystemWorkflow(request, workflowId);
- showSuccess(t('Workflow gelöscht'));
- await _load();
- return true;
- } catch (e: any) {
- showError(t('Fehler: {msg}', { msg: e?.message || t('Löschen fehlgeschlagen') }));
- return false;
- }
- }, [request, showSuccess, showError, _load, t]);
-
- const _handleToggleActive = useCallback(async (row: SystemWorkflow) => {
- if (!row.featureInstanceId) return;
- const next = !(row.active !== false);
- setTogglingId(row.id);
- try {
- await updateWorkflow(request, row.featureInstanceId, row.id, { active: next });
- showSuccess(next ? t('Workflow aktiviert') : t('Workflow deaktiviert'));
- await _load();
- } catch (e: any) {
- showError(t('Fehler: {msg}', { msg: e?.message || t('Status-Update fehlgeschlagen') }));
- } finally {
- setTogglingId(null);
- }
- }, [request, showSuccess, showError, _load, t]);
-
- const _handleRename = useCallback(async (row: SystemWorkflow) => {
- if (!row.featureInstanceId) return;
- const newLabel = await promptInput(t('Neuer Name:'), {
- title: t('Workflow umbenennen'),
- defaultValue: row.label,
- placeholder: t('Workflow-Name'),
- });
- if (!newLabel || newLabel.trim() === row.label) return;
- try {
- await updateWorkflow(request, row.featureInstanceId, row.id, { label: newLabel.trim() });
- showSuccess(t('Workflow umbenannt'));
- await _load();
- } catch (e: any) {
- showError(t('Fehler: {msg}', { msg: e?.message || t('Umbenennen fehlgeschlagen') }));
- }
- }, [request, promptInput, showSuccess, showError, _load, t]);
-
- const _handleExecute = useCallback(async (row: SystemWorkflow) => {
- if (!row.featureInstanceId) return;
- setExecutingId(row.id);
- let observedFailure = false;
- let observedSuccess = false;
- try {
- const invs = row.invocations || [];
- const primary =
- invs.find((i) => i.enabled && i.kind === 'manual') ||
- invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api'));
- const emptyGraph = { nodes: [], connections: [] };
- const exec = executeGraph(request, row.featureInstanceId, emptyGraph as any, row.id, {
- ...(primary ? { entryPointId: primary.id } : {}),
- }).then((result) => {
- if (result?.success) {
- observedSuccess = true;
- showSuccess(result?.paused
- ? t('Workflow pausiert bei Human Task.')
- : t('Workflow abgeschlossen'));
- } else {
- observedFailure = true;
- showError(result?.error || t('Ausführung fehlgeschlagen'));
- }
- _load();
- }).catch((e: any) => {
- observedFailure = true;
- showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') }));
- _load();
- });
- await Promise.race([
- exec,
- new Promise((r) => setTimeout(r, 1000)),
- ]);
- await _load();
- if (!observedFailure && !observedSuccess) {
- showSuccess(t('Workflow gestartet'));
- }
- } finally {
- setExecutingId(null);
- }
- }, [request, showSuccess, showError, _load, t]);
-
- const [stoppingId, setStoppingId] = useState(null);
-
- const _handleStop = useCallback(async (row: SystemWorkflow) => {
- if (!row.activeRunId) return;
- setStoppingId(row.id);
- try {
- await api.post(`/api/system/workflow-runs/${row.activeRunId}/stop`);
- showSuccess(t('Stop-Signal gesendet'));
- await _load();
- } catch (e: any) {
- showError(t('Fehler: {msg}', { msg: e?.message || t('Stoppen fehlgeschlagen') }));
- } finally {
- setStoppingId(null);
- }
- }, [showSuccess, showError, _load, t]);
-
- const _hasManualTrigger = useCallback((row: SystemWorkflow): boolean => {
- const invs = row.invocations || [];
- return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
- }, []);
-
- const _rawColumns: ColumnConfig[] = useMemo(() => [
- { key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true },
- {
- key: 'mandateId',
- label: t('Mandant'),
- width: 140,
- sortable: true,
- filterable: true,
- displayField: 'mandateLabel',
- },
- {
- key: 'featureInstanceId',
- label: t('Instanz'),
- width: 140,
- sortable: true,
- filterable: true,
- displayField: 'instanceLabel',
- },
- {
- key: 'ownerId',
- label: t('Benutzer'),
- width: 140,
- sortable: true,
- filterable: true,
- displayField: 'ownerLabel',
- },
- {
- key: 'active',
- label: t('Aktiv'),
- width: 80,
- sortable: true,
- filterable: true,
- },
- {
- key: 'isRunning',
- label: t('Läuft'),
- width: 80,
- sortable: true,
- filterable: true,
- },
- {
- key: 'sysCreatedAt',
- label: t('Erstellt'),
- width: 140,
- sortable: true,
- filterable: true,
- formatter: (v: number) => _formatTs(v),
- },
- {
- key: 'lastStartedAt',
- label: t('Zuletzt gestartet'),
- width: 160,
- sortable: true,
- filterable: true,
- formatter: (v: number) => _formatTs(v),
- },
- {
- key: 'runCount',
- label: t('Läufe'),
- width: 80,
- sortable: true,
- filterable: true,
- formatter: (v: number) => (v != null ? String(v) : '0'),
- },
- ], [t]);
-
- const _columns = useMemo(
- () => resolveColumnTypes(_rawColumns, backendAttributes),
- [_rawColumns, backendAttributes],
- );
-
- const _hookData = useMemo(() => ({
- refetch: _load,
- handleDelete: (id: string) => _handleDelete(id),
- pagination: paginationMeta,
- }), [_load, _handleDelete, paginationMeta]);
-
- return (
- <>
-
-
-
- {t('Alle Workflows über alle Features und Mandanten')}
-
-
-
-
- {(['all', 'active', 'inactive'] as const).map((f) => (
-
- ))}
-
-
-
-
-
-
-
- data={workflows}
- columns={_columns}
- loading={loading}
- pagination={true}
- pageSize={25}
- searchable={true}
- filterable={true}
- sortable={true}
- selectable={true}
- initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]}
- apiEndpoint="/api/system/workflow-runs/workflows"
- actionButtons={[
- {
- type: 'edit',
- title: t('bearbeiten'),
- onAction: _handleEdit,
- visible: (row: SystemWorkflow) => row.canEdit === true,
- },
- {
- type: 'delete',
- title: t('löschen'),
- visible: (row: SystemWorkflow) => row.canDelete === true,
- },
- ]}
- customActions={[
- {
- id: 'view',
- icon: ,
- title: t('anzeigen'),
- onClick: (row) => _handleEdit(row),
- visible: (row) => row.canEdit !== true,
- },
- {
- id: 'rename',
- icon: ,
- title: t('umbenennen'),
- onClick: (row) => _handleRename(row),
- visible: (row) => row.canEdit === true,
- },
- {
- id: 'activate',
- icon: ,
- title: t('aktivieren'),
- onClick: (row) => _handleToggleActive(row),
- loading: (row) => togglingId === row.id,
- visible: (row) => row.canEdit === true && row.active === false,
- },
- {
- id: 'deactivate',
- icon: ,
- title: t('deaktivieren'),
- onClick: (row) => _handleToggleActive(row),
- loading: (row) => togglingId === row.id,
- visible: (row) => row.canEdit === true && row.active !== false,
- },
- {
- id: 'execute',
- icon: ,
- title: t('ausführen'),
- onClick: (row) => _handleExecute(row),
- loading: (row) => executingId === row.id,
- visible: (row) => row.canExecute === true && _hasManualTrigger(row) && !row.isRunning,
- },
- {
- id: 'stop',
- icon: ,
- title: t('stoppen'),
- onClick: (row) => _handleStop(row),
- loading: (row) => stoppingId === row.id,
- visible: (row) => row.isRunning === true && !!row.activeRunId,
- },
- ]}
- onDelete={(row) => _handleDelete(row.id)}
- onRowClick={(row) => onWorkflowClick?.(row.id)}
- hookData={_hookData}
- emptyMessage={t('Keine Workflows gefunden.')}
- />
-
-
- >
- );
-};
-
-// ===========================================================================
-// Workspace Tab (run detail only — no table)
-// ===========================================================================
-
-const _FILE_REF_KEYS = new Set(['fileId', 'documentId', 'fileIds', 'documents']);
-
-function _isPlainObject(v: unknown): v is Record {
- return typeof v === 'object' && v !== null && !Array.isArray(v);
-}
-
-function _stripFileRefKeys(value: unknown): unknown {
- if (_isPlainObject(value)) {
- const out: Record = {};
- for (const [k, v] of Object.entries(value)) {
- if (_FILE_REF_KEYS.has(k)) continue;
- const stripped = _stripFileRefKeys(v);
- if (stripped !== undefined) out[k] = stripped;
- }
- return Object.keys(out).length > 0 ? out : undefined;
- }
- if (Array.isArray(value)) {
- const out = value.map((v) => _stripFileRefKeys(v)).filter((v) => v !== undefined);
- return out.length > 0 ? out : undefined;
- }
- return value;
-}
-
-function _formatScalar(v: unknown): string {
- if (v === null || v === undefined) return '—';
- if (typeof v === 'string') return v;
- if (typeof v === 'number' || typeof v === 'boolean') return String(v);
- return JSON.stringify(v);
-}
-
-const _DataBlock: React.FC<{ data: unknown; emptyHint?: string }> = ({ data, emptyHint }) => {
- if (data === undefined || data === null) {
- return emptyHint ? {emptyHint}
: null;
- }
-
- if (_isPlainObject(data)) {
- const entries = Object.entries(data);
- if (entries.length === 0) {
- return emptyHint ? {emptyHint}
: null;
- }
- return (
-
- {entries.map(([k, v]) => {
- const isComplex = _isPlainObject(v) || Array.isArray(v);
- if (isComplex) {
- return (
-
-
- {k}
-
-
- {JSON.stringify(v, null, 2)}
-
-
- );
- }
- return (
-
- {k}
- {_formatScalar(v)}
-
- );
- })}
-
- );
- }
-
- return (
-
- {JSON.stringify(data, null, 2)}
-
- );
-};
-
-const _FileLinkList: React.FC<{ files: Array<{ id: string; fileName?: string }> }> = ({ files }) => {
- if (!files.length) return null;
- const baseUrl = api.defaults.baseURL || '';
- return (
-
- {files.map((f) => (
-
-
- {f.fileName || f.id}
-
- ))}
-
- );
-};
-
-const _INTERNAL_EXTRACT_FILENAME_SUBSTR = 'extracted_content_transient';
-
-function _isHiddenWorkflowArtifactFile(f: { fileName?: string }): boolean {
- return (f.fileName ?? '').toLowerCase().includes(_INTERNAL_EXTRACT_FILENAME_SUBSTR);
-}
-
-const _ProducedFilesSection: React.FC<{
- steps: Array<{ outputFiles?: Array<{ id: string; fileName?: string }> }>;
- unassignedFiles?: Array<{ id: string; fileName?: string }>;
-}> = ({ steps, unassignedFiles }) => {
- const { t } = useLanguage();
- const seen = new Set();
- const allFiles: Array<{ id: string; fileName?: string }> = [];
- for (const step of steps) {
- for (const f of step.outputFiles ?? []) {
- if (_isHiddenWorkflowArtifactFile(f)) continue;
- if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); }
- }
- }
- for (const f of unassignedFiles ?? []) {
- if (_isHiddenWorkflowArtifactFile(f)) continue;
- if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); }
- }
- if (!allFiles.length) return null;
- const baseUrl = api.defaults.baseURL || '';
- return (
-
-
-
- {t('Ergebnisse')} ({allFiles.length})
-
-
- {allFiles.map(f => (
-
-
- {f.fileName || f.id}
-
- ))}
-
-
- );
-};
-
-function _downloadJson(data: unknown, fileName: string) {
- const json = JSON.stringify(data, null, 2);
- const blob = new Blob([json], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = fileName;
- a.click();
- URL.revokeObjectURL(url);
-}
-
-interface _WorkspaceTabProps {
- runId: string | null;
- onBack: () => void;
-}
-
-const _TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'error', 'stopped']);
-const _POLL_INTERVAL_MS = 3000;
-
-const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
- const { t } = useLanguage();
- const { request } = useApiRequest();
- const [runDetail, setRunDetail] = useState> | null>(null);
- const [detailLoading, setDetailLoading] = useState(false);
-
- const _loadDetail = useCallback(async (id: string) => {
- setDetailLoading(true);
- try {
- const detail = await fetchWorkspaceRunDetail(request, id);
- setRunDetail(detail);
- } catch (e) {
- console.error('Workspace run detail failed', e);
- } finally {
- setDetailLoading(false);
- }
- }, [request]);
-
- useEffect(() => {
- if (runId) _loadDetail(runId);
- else setRunDetail(null);
- }, [runId, _loadDetail]);
-
- useEffect(() => {
- if (!runId || !runDetail) return;
- const status = runDetail.run?.status;
- if (status && _TERMINAL_STATUSES.has(status)) return;
- const timer = setInterval(() => {
- fetchWorkspaceRunDetail(request, runId)
- .then(detail => setRunDetail(detail))
- .catch(() => {});
- }, _POLL_INTERVAL_MS);
- return () => clearInterval(timer);
- }, [runId, runDetail, request]);
-
- if (!runId) {
- return (
-
- {t('Wähle einen Run im Dashboard aus, um die Details anzuzeigen.')}
-
- );
- }
-
- if (detailLoading || !runDetail) {
- return {t('Laden…')}
;
- }
-
- const { run, steps, workflow, unassignedFiles } = runDetail;
-
- return (
-
-
- {run.workflowLabel || run.workflowId}
-
- {t('Status')}: {run.status}
- {run.startedAt && {t('Start')}: {_formatTs(run.startedAt)}}
- {run.completedAt && {t('Ende')}: {_formatTs(run.completedAt)}}
- {workflow?.targetFeatureInstanceId && {t('Ziel-Instanz')}: {run.targetInstanceLabel || workflow.targetFeatureInstanceId}}
- {(run.costTokens ?? 0) > 0 && Tokens: {run.costTokens}}
-
- {run.error && (
-
- {run.error}
-
- )}
- <_ProducedFilesSection steps={steps} unassignedFiles={unassignedFiles} />
- {t('Schritte')}
- {steps.length === 0 ? (
- {t('Keine Schritte protokolliert.')}
- ) : (
-
- {steps.map((step) => {
- const inputData = _stripFileRefKeys(step.inputSnapshot ?? {});
- const outputData = _stripFileRefKeys(step.output ?? {});
- const inputFiles = (step.inputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f));
- const outputFiles = (step.outputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f));
- const hasInput = inputData !== undefined || inputFiles.length > 0;
- const hasOutput = outputData !== undefined || outputFiles.length > 0;
- return (
-
-
-
- {step.status}
-
- {step.nodeType} ({step.nodeId})
- {step.durationMs != null && {step.durationMs}ms}
- {(step.tokensUsed ?? 0) > 0 && {step.tokensUsed} tokens}
-
-
- {hasInput && (
-
-
- {t('Input')}
- {inputData !== undefined && inputData !== null && (
-
- )}
-
- <_DataBlock data={inputData} />
- <_FileLinkList files={inputFiles} />
-
- )}
- {hasOutput && (
-
-
- {t('Output')}
- {outputData !== undefined && outputData !== null && (
-
- )}
-
- <_DataBlock data={outputData} />
- <_FileLinkList files={outputFiles} />
-
- )}
- {step.error && (
-
-
- {t('Fehler')}
-
- {step.error}
-
- )}
-
- {step.startedAt && {t('Start')}: {_formatTs(step.startedAt)}}
- {step.completedAt && {t('Ende')}: {_formatTs(step.completedAt)}}
- {(step.retryCount ?? 0) > 0 && {t('Wiederholungen')}: {step.retryCount}}
-
-
-
- );
- })}
-
- )}
- {(() => {
- const visibleUnassigned = (unassignedFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f));
- if (!visibleUnassigned.length) return null;
- return (
- <>
- {t('Sonstige Dokumente')}
- <_FileLinkList files={visibleUnassigned} />
- >
- );
- })()}
-
- );
-};
-
-// ===========================================================================
-// Editor Tab — wraps GraphicalEditorPage with auto-resolved instanceId
-// ===========================================================================
-
-const _EditorTab: React.FC = () => {
- const { t } = useLanguage();
- const { dynamicBlock } = useNavigation();
-
- const editorInstance = useMemo(
- () => _findAnyEditorInstance(dynamicBlock),
- [dynamicBlock],
- );
-
- if (!editorInstance) {
- return (
-
- {t('Kein Editor verfügbar. Bitte erstelle zuerst eine Feature-Instanz mit Editor (Workspace oder Graphical Editor).')}
-
- );
- }
-
- return (
-
-
-
- );
-};
-
-// ===========================================================================
-// Templates Tab — wraps GraphicalEditorTemplatesPage with auto-resolved instanceId
-// ===========================================================================
-
-const _TemplatesTab: React.FC = () => {
- const { t } = useLanguage();
- const { dynamicBlock } = useNavigation();
-
- const editorInstance = useMemo(
- () => _findAnyEditorInstance(dynamicBlock),
- [dynamicBlock],
- );
-
- if (!editorInstance) {
- return (
-
- {t('Kein Editor verfügbar. Bitte erstelle zuerst eine Feature-Instanz mit Editor.')}
-
- );
- }
-
- return (
-
- );
-};
-
-// ===========================================================================
-// Main page with Tabs
-// ===========================================================================
-
-const _TAB_ALIASES: Record = {
- dashboard: 'runs',
- workspace: 'detail',
-};
-
-export const WorkflowAutomationPage: React.FC = () => {
- const { t } = useLanguage();
- const [searchParams] = useSearchParams();
-
- const rawTab = searchParams.get('tab') || 'workflows';
- const initialTab = _TAB_ALIASES[rawTab] || rawTab;
- const initialRunId = searchParams.get('runId') || null;
-
- const [activeTab, setActiveTab] = useState(initialRunId ? 'detail' : initialTab);
- const [selectedRunId, setSelectedRunId] = useState(initialRunId);
- const [workflowFilter, setWorkflowFilter] = useState(null);
-
- const _handleWorkflowClick = useCallback((workflowId: string) => {
- setWorkflowFilter(workflowId);
- setActiveTab('runs');
- }, []);
-
- useEffect(() => {
- if (workflowFilter) setWorkflowFilter(null);
- }, [workflowFilter]);
-
- const _handleRunClick = useCallback((runId: string) => {
- setSelectedRunId(runId);
- setActiveTab('detail');
- }, []);
-
- const _handleBackFromWorkspace = useCallback(() => {
- setSelectedRunId(null);
- setActiveTab('runs');
- }, []);
-
- const tabs = useMemo(() => [
- {
- id: 'workflows',
- label: t('Workflows'),
- content: <_WorkflowsTab onWorkflowClick={_handleWorkflowClick} />,
- },
- {
- id: 'editor',
- label: t('Editor'),
- content: <_EditorTab />,
- },
- {
- id: 'templates',
- label: t('Vorlagen'),
- content: <_TemplatesTab />,
- },
- {
- id: 'runs',
- label: t('Workflow-Durchläufe'),
- content: <_DashboardTab workflowFilter={workflowFilter} onRunClick={_handleRunClick} />,
- },
- {
- id: 'detail',
- label: t('Durchlauf-Details'),
- content: <_WorkspaceTab runId={selectedRunId} onBack={_handleBackFromWorkspace} />,
- },
- ], [t, _handleWorkflowClick, workflowFilter, _handleRunClick, selectedRunId, _handleBackFromWorkspace]);
-
- return (
-
- {t('Workflow-Automation')}
-
-
- );
-};
-
-export default WorkflowAutomationPage;
diff --git a/src/pages/views/graphicalEditor/Automation2WorkflowsTasks.module.css b/src/pages/views/graphicalEditor/Automation2WorkflowsTasks.module.css
deleted file mode 100644
index 47c2c39..0000000
--- a/src/pages/views/graphicalEditor/Automation2WorkflowsTasks.module.css
+++ /dev/null
@@ -1,513 +0,0 @@
-.pageLayout {
- display: flex;
- align-items: flex-start;
- gap: 1.5rem;
- padding: 1.5rem;
- max-width: 1400px;
-}
-
-.mainColumn {
- flex: 1;
- min-width: 0;
-}
-
-.startSidebar {
- flex: 0 0 300px;
- position: sticky;
- top: 1rem;
- max-height: calc(100vh - 2rem);
- display: flex;
- flex-direction: column;
- border: 1px solid var(--border-color, #e0e0e0);
- border-radius: 8px;
- background: var(--bg-secondary, #f8f9fa);
- overflow: hidden;
-}
-
-.startSidebarTitle {
- margin: 0;
- padding: 0.75rem 1rem;
- font-size: 0.95rem;
- font-weight: 600;
- border-bottom: 1px solid var(--border-color, #e0e0e0);
- background: var(--bg-primary, #fff);
-}
-
-.startSidebarList {
- margin: 0;
- padding: 0.5rem;
- overflow-y: auto;
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
-}
-
-.startWorkflowRow {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.6rem 0.65rem;
- border-radius: 6px;
- background: var(--bg-primary, #fff);
- border: 1px solid var(--border-color, #e0e0e0);
-}
-
-.startWorkflowInfo {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 0.2rem;
-}
-
-.startWorkflowName {
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--text-primary, #333);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.startWorkflowKind {
- font-size: 0.7rem;
- font-weight: 600;
- text-transform: uppercase;
- color: var(--text-secondary, #666);
-}
-
-.startButton {
- flex-shrink: 0;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0.4rem 0.65rem;
- border: none;
- border-radius: 6px;
- background: var(--primary-color, #007bff);
- color: white;
- cursor: pointer;
- font-size: 0.8rem;
-}
-
-.startButton:hover:not(:disabled) {
- opacity: 0.9;
-}
-
-.startButton:disabled {
- opacity: 0.6;
- cursor: not-allowed;
-}
-
-@media (max-width: 900px) {
- .pageLayout {
- flex-direction: column;
- }
-
- .startSidebar {
- position: static;
- max-height: none;
- width: 100%;
- flex: none;
- }
-}
-
-.container {
- padding: 0;
- width: 100%;
-}
-
-.container h2 {
- margin: 0 0 1rem 0;
- font-size: 1.25rem;
-}
-
-.section {
- margin-bottom: 1.5rem;
-}
-
-.sectionTitle {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- margin: 0 0 0.75rem 0;
- font-size: 1rem;
- font-weight: 600;
-}
-
-.completedHeader {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- width: 100%;
- padding: 0.6rem 0;
- text-align: left;
- background: none;
- border: none;
- cursor: pointer;
- font-size: 1rem;
- font-weight: 600;
- color: var(--text-primary, #333);
-}
-
-.completedHeader:hover {
- color: var(--primary-color, #007bff);
-}
-
-.completedList {
- max-height: 360px;
- overflow-y: auto;
- padding-top: 0.5rem;
-}
-
-.taskMeta {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
- gap: 0.5rem 1.25rem;
- margin-bottom: 0.75rem;
- padding-bottom: 0.75rem;
- border-bottom: 1px solid var(--border-color, #e0e0e0);
-}
-
-.taskMetaRow {
- display: flex;
- flex-direction: column;
- gap: 0.2rem;
-}
-
-.metaLabel {
- font-size: 0.7rem;
- font-weight: 600;
- text-transform: uppercase;
- color: var(--text-secondary, #666);
-}
-
-.metaValue {
- font-size: 0.9rem;
- color: var(--text-primary, #333);
-}
-
-.metaValueMono {
- font-size: 0.75rem;
- font-family: monospace;
- color: var(--text-secondary, #666);
-}
-
-.loading {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 1rem;
- padding: 3rem;
- color: var(--text-secondary, #666);
-}
-
-.spinner {
- animation: spin 1s linear infinite;
-}
-
-@keyframes spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
-
-.placeholder {
- padding: 2rem;
- text-align: center;
- color: var(--text-secondary, #666);
-}
-
-.workflowList {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
-}
-
-.workflowItem {
- border: 1px solid var(--border-color, #e0e0e0);
- border-radius: 8px;
- overflow: hidden;
- background: var(--bg-primary, #fff);
-}
-
-.workflowHeader {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- width: 100%;
- padding: 0.75rem 1rem;
- text-align: left;
- background: var(--bg-secondary, #f8f9fa);
- border: none;
- cursor: pointer;
- font-size: 1rem;
-}
-
-.workflowHeader:hover {
- background: var(--bg-hover, #e9ecef);
-}
-
-.badge {
- margin-left: auto;
- background: var(--primary-color, #007bff);
- color: white;
- padding: 0.2rem 0.5rem;
- border-radius: 12px;
- font-size: 0.8rem;
-}
-
-.taskList {
- padding: 1rem;
- border-top: 1px solid var(--border-color, #e0e0e0);
-}
-
-.empty {
- color: var(--text-tertiary, #999);
- font-size: 0.9rem;
- margin: 0;
-}
-
-.taskCard {
- padding: 1rem;
- margin-bottom: 0.75rem;
- border: 1px solid var(--border-color, #e0e0e0);
- border-radius: 6px;
- background: var(--bg-primary, #fff);
-}
-
-.taskCardDismissable {
- position: relative;
- padding-top: 0.85rem;
- padding-right: 2.25rem;
-}
-
-.dismissOpenTaskBtn {
- position: absolute;
- top: 0.35rem;
- right: 0.35rem;
- width: 1.85rem;
- height: 1.85rem;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- border: none;
- border-radius: 50%;
- background: transparent;
- color: var(--text-secondary, #888);
- cursor: pointer;
- font-size: 1rem;
- line-height: 1;
-}
-
-.dismissOpenTaskBtn:hover:not(:disabled) {
- color: var(--danger-color, #c82333);
- background: rgba(220, 53, 69, 0.08);
-}
-
-.dismissOpenTaskBtn:focus-visible {
- outline: 2px solid var(--primary-color, #007bff);
- outline-offset: 2px;
-}
-
-.dismissOpenTaskBtn:disabled {
- opacity: 0.45;
- cursor: not-allowed;
-}
-
-.taskCard:last-child {
- margin-bottom: 0;
-}
-
-.taskType {
- font-size: 0.75rem;
- font-weight: 600;
- text-transform: uppercase;
- color: var(--text-secondary, #666);
- margin-bottom: 0.5rem;
-}
-
-.formFields {
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
-}
-
-.formFields button {
- margin-top: 0.75rem;
- align-self: flex-start;
-}
-
-.formFields label,
-.taskCard label {
- display: block;
- font-size: 0.875rem;
- margin-top: 0.5rem;
- margin-bottom: 0.25rem;
-}
-
-.formFields input[type='text'],
-.formFields input[type='number'],
-.formFields input[type='date'],
-.formFields select,
-.taskCard input[type='text'],
-.taskCard input[type='number'],
-.taskCard textarea {
- width: 100%;
- padding: 0.5rem;
- border: 1px solid var(--border-color, #e0e0e0);
- border-radius: 4px;
-}
-
-.taskCard textarea {
- min-height: 80px;
- margin-bottom: 0.5rem;
-}
-
-.openFormButton {
- margin-top: 0.5rem;
- padding: 0.5rem 1rem;
- background: var(--primary-color, #007bff);
- color: white;
- border: none;
- border-radius: 6px;
- font-size: 0.9rem;
- cursor: pointer;
-}
-
-.openFormButton:hover:not(:disabled) {
- opacity: 0.9;
-}
-
-.openFormButton:disabled {
- opacity: 0.6;
- cursor: not-allowed;
-}
-
-.popupSubmitButton {
- padding: 0.5rem 1.25rem;
- background: var(--success-color, #28a745);
- color: white;
- border: none;
- border-radius: 6px;
- font-size: 0.9rem;
- cursor: pointer;
-}
-
-.popupSubmitButton:hover:not(:disabled) {
- opacity: 0.9;
-}
-
-.popupSubmitButton:disabled {
- opacity: 0.6;
- cursor: not-allowed;
-}
-
-.approvalButtons {
- display: flex;
- gap: 0.5rem;
- margin-top: 0.75rem;
-}
-
-.approvalButtons button,
-.taskCard button {
- padding: 0.5rem 1rem;
- border-radius: 6px;
- border: none;
- cursor: pointer;
- font-size: 0.9rem;
-}
-
-.approvalButtons button:first-child,
-.taskCard button[type='button'] {
- background: var(--primary-color, #007bff);
- color: white;
-}
-
-.approvalButtons button:last-of-type:not(:first-child) {
- background: var(--danger-color, #dc3545);
- color: white;
-}
-
-.approvalButtons button:disabled,
-.taskCard button:disabled {
- opacity: 0.6;
- cursor: not-allowed;
-}
-
-/* Override broad .taskCard button[type='button'] primary styling for dismiss control */
-.taskCard button.dismissOpenTaskBtn {
- background: transparent;
- color: var(--text-secondary, #888);
- padding: 0;
-}
-
-/* Upload task */
-.uploadTaskBlock {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
- margin-top: 0.5rem;
-}
-
-.uploadTaskBlock .uploadButton {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.5rem 1rem;
- background: var(--primary-color, #007bff);
- color: white;
- border: none;
- border-radius: 6px;
- font-size: 0.9rem;
- cursor: pointer;
- align-self: flex-start;
-}
-
-.uploadTaskBlock .uploadButton:hover:not(:disabled) {
- opacity: 0.9;
-}
-
-.uploadTaskBlock .uploadButton:disabled {
- opacity: 0.6;
- cursor: not-allowed;
-}
-
-.uploadTaskBlock .uploadError {
- margin: 0;
- font-size: 0.875rem;
- color: var(--danger-color, #dc3545);
-}
-
-.uploadTaskBlock .uploadedList {
- margin: 0;
- padding-left: 1.25rem;
- font-size: 0.875rem;
- color: var(--text-secondary, #666);
-}
-
-/* Output section */
-.outputContent {
- margin-top: 0.5rem;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
-}
-
-.outputContent .metaLabel {
- margin-top: 0.25rem;
-}
-
-.outputContent .uploadedList {
- margin-top: 0.2rem;
-}
-
-.downloadLink {
- color: var(--primary-color, #007bff);
- text-decoration: none;
-}
-
-.downloadLink:hover {
- text-decoration: underline;
-}
diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx
deleted file mode 100644
index a63e162..0000000
--- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx
+++ /dev/null
@@ -1,943 +0,0 @@
-/**
- * GraphicalEditorWorkflowsTasksPage
- * Tasks only (no workflow grouping).
- * Open tasks at top, completed tasks at bottom (expandable, scrollable).
- * Each task shows workflow, created, due, step, type, and action.
- * Right column: active workflows with manual or form entry point — start via execute (same as Workflows page).
- */
-import React, { useState, useEffect, useCallback, useRef } from 'react';
-import { Link } from 'react-router-dom';
-import { FaChevronDown, FaChevronRight, FaPlay, FaSpinner, FaTimes, FaUpload } from 'react-icons/fa';
-import { useInstanceId } from '../../../hooks/useCurrentInstance';
-import { useApiRequest } from '../../../hooks/useApi';
-import {
- fetchTasks,
- cancelPendingTaskStopRun,
- completeTask,
- fetchCompletedRuns,
- fetchWorkflows,
- executeGraph,
- type Automation2Task,
- type Automation2Workflow,
- type CompletedRun,
-} from '../../../api/workflowApi';
-import { useToast } from '../../../contexts/ToastContext';
-import { Popup } from '../../../components/UiComponents/Popup';
-import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor';
-import { useFileOperations } from '../../../hooks/useFiles';
-import styles from './Automation2WorkflowsTasks.module.css';
-import {
- WorkflowRuntimeFormFields,
- useWorkflowRuntimeFormRequiredOk,
- type WorkflowRuntimeFormFieldRow,
-} from '../../../components/FlowEditor/workflowRuntime/WorkflowRuntimeFormFields';
-
-import { useLanguage } from '../../../providers/language/LanguageContext';
-
-function _nodeTypeLabel(nodeType: string, t: (k: string) => string): string {
- switch (nodeType) {
- case 'input.form': return t('Formular');
- case 'input.approval': return t('Genehmigung');
- case 'input.upload': return t('Upload');
- case 'input.comment': return t('Kommentar');
- case 'input.review': return t('Prüfung');
- case 'input.selection': return t('Auswahl');
- case 'input.confirmation': return t('Bestätigung');
- default: return nodeType;
- }
-}
-
-function formatTimestamp(ts?: number): string {
- if (ts == null || ts <= 0) return '—';
- const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts);
- return d.toLocaleString('de-DE', {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- });
-}
-
-function getNodeStepLabel(config: Record): string {
- const title = config?.title;
- if (typeof title === 'string' && title.trim()) return title;
- const label = config?.label;
- if (typeof label === 'string' && label.trim()) return label;
- if (typeof label === 'object' && label != null && 'de' in (label as Record)) {
- return (label as Record).de ?? (label as Record).en ?? '';
- }
- return '';
-}
-
-/** Active workflow with at least one enabled manual or form start (same idea as Tasks / editor on-demand). */
-function hasManualOrFormInvocation(wf: Automation2Workflow): boolean {
- const invs = wf.invocations || [];
- return invs.some(
- (i) => i.enabled !== false && (i.kind === 'manual' || i.kind === 'form')
- );
-}
-
-/**
- * Primary entry for execute — align with first start node in graph order (backend-driven),
- * then fall back to manual / form / api on invocations list.
- */
-function getPrimaryEntryPoint(wf: Automation2Workflow) {
- const invs = wf.invocations || [];
- const nodes = wf.graph?.nodes ?? [];
- for (const n of nodes) {
- const nodeType = n.type;
- if (typeof nodeType === 'string' && nodeType.startsWith('trigger.')) {
- const inv = invs.find((i) => i.enabled !== false && i.id === n.id);
- if (inv) return inv;
- }
- }
- return (
- invs.find((i) => i.enabled !== false && i.kind === 'manual') ||
- invs.find((i) => i.enabled !== false && (i.kind === 'form' || i.kind === 'api'))
- );
-}
-
-/** Form field rows from graph trigger.form for workflow list (parameters.formFields). */
-function getTriggerFormFieldsForWorkflow(wf: Automation2Workflow): WorkflowRuntimeFormFieldRow[] {
- const primary = getPrimaryEntryPoint(wf);
- if (!primary || primary.kind !== 'form') return [];
- const nodes = wf.graph?.nodes ?? [];
- let node = nodes.find((n) => n.id === primary.id && n.type === 'trigger.form');
- if (!node) node = nodes.find((n) => n.type === 'trigger.form');
- if (!node) return [];
- const raw = (node.parameters as Record | undefined)?.formFields;
- if (!Array.isArray(raw)) return [];
- return raw as WorkflowRuntimeFormFieldRow[];
-}
-
-function primaryKindLabel(kind: string): string {
- if (kind === 'form') return 'Formular';
- if (kind === 'manual') return 'Manuell';
- return kind;
-}
-
-export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
- const { t } = useLanguage();
-
- const instanceId = useInstanceId();
- const { request } = useApiRequest();
- const { showSuccess, showError } = useToast();
- const [tasks, setTasks] = useState([]);
- const [completedRuns, setCompletedRuns] = useState([]);
- const [startableWorkflows, setStartableWorkflows] = useState([]);
- const [loading, setLoading] = useState(true);
- const [completedExpanded, setCompletedExpanded] = useState(false);
- const [outputExpanded, setOutputExpanded] = useState(true);
- const [submitting, setSubmitting] = useState(null);
- const [dismissingTaskId, setDismissingTaskId] = useState(null);
- const [executingWorkflowId, setExecutingWorkflowId] = useState(null);
- const [formStartWorkflow, setFormStartWorkflow] = useState(null);
- const [formStartFields, setFormStartFields] = useState([]);
- const [startFormData, setStartFormData] = useState>({});
-
- const load = useCallback(async () => {
- if (!instanceId) return;
- setLoading(true);
- try {
- const [taskList, runs] = await Promise.all([
- fetchTasks(request, instanceId),
- fetchCompletedRuns(request, instanceId, 20),
- ]);
- setTasks(taskList);
- setCompletedRuns(runs);
- try {
- const activeWfs = await fetchWorkflows(request, instanceId, { active: true });
- const list: Automation2Workflow[] = Array.isArray(activeWfs)
- ? activeWfs
- : (activeWfs && typeof activeWfs === 'object' && 'items' in activeWfs && Array.isArray((activeWfs as { items: Automation2Workflow[] }).items)
- ? (activeWfs as { items: Automation2Workflow[] }).items
- : []);
- setStartableWorkflows(
- list.filter(
- (w) => w.active !== false && hasManualOrFormInvocation(w)
- )
- );
- } catch (we) {
- console.error('[graphicalEditor] load startable workflows failed', we);
- setStartableWorkflows([]);
- }
- } catch (e) {
- console.error('[graphicalEditor] load failed', e);
- } finally {
- setLoading(false);
- }
- }, [instanceId, request]);
-
- useEffect(() => {
- load();
- }, [load]);
-
- const handleComplete = async (taskId: string, result: Record) => {
- if (!instanceId) return;
- setSubmitting(taskId);
- try {
- await completeTask(request, instanceId, taskId, result);
- await load();
- } catch (e) {
- console.error('[graphicalEditor] complete failed', e);
- } finally {
- setSubmitting(null);
- }
- };
-
- const handleDismissOpenTask = async (taskId: string) => {
- if (!instanceId) return;
- setDismissingTaskId(taskId);
- try {
- const res = await cancelPendingTaskStopRun(request, instanceId, taskId);
- if (res.success) {
- showSuccess(t('Ausführung abgebrochen'));
- await load();
- } else {
- showError(t('Abbrechen fehlgeschlagen'));
- }
- } catch (e: unknown) {
- const msg =
- (e as { message?: string })?.message ?? t('Abbrechen fehlgeschlagen');
- showError(msg);
- console.error('[graphicalEditor] cancel task failed', e);
- } finally {
- setDismissingTaskId(null);
- }
- };
-
- const handleStartWorkflow = useCallback(
- async (wf: Automation2Workflow) => {
- if (!instanceId || !wf.graph) return;
- const primary = getPrimaryEntryPoint(wf);
- if (primary?.kind === 'form') {
- setFormStartFields(getTriggerFormFieldsForWorkflow(wf));
- setStartFormData({});
- setFormStartWorkflow(wf);
- return;
- }
- setExecutingWorkflowId(wf.id);
- try {
- const result = await executeGraph(request, instanceId, wf.graph, wf.id, {
- ...(primary ? { entryPointId: primary.id } : {}),
- });
- if (result?.success) {
- if (result?.paused) {
- showSuccess(t('Workflow gestartet und bei Human Task pausiert.'));
- } else {
- showSuccess(t('Workflow gestartet'));
- }
- await load();
- } else {
- showError(result?.error || t('Ausführung fehlgeschlagen'));
- }
- } catch (e: unknown) {
- const msg =
- (e as { message?: string })?.message ?? t('Ausführung fehlgeschlagen');
- showError(msg);
- } finally {
- setExecutingWorkflowId(null);
- }
- },
- [instanceId, request, showSuccess, showError, load, t]
- );
-
- const formStartRequiredOk = useWorkflowRuntimeFormRequiredOk(formStartFields, startFormData);
-
- const handleFormStartSubmit = useCallback(async () => {
- if (!instanceId || !formStartWorkflow?.graph) return;
- const wf = formStartWorkflow;
- const primary = getPrimaryEntryPoint(wf);
- const payload = { ...startFormData };
- setExecutingWorkflowId(wf.id);
- try {
- const result = await executeGraph(request, instanceId, wf.graph, wf.id, {
- ...(primary ? { entryPointId: primary.id } : {}),
- payload,
- });
- if (result?.success) {
- if (result?.paused) {
- showSuccess(t('Workflow gestartet und bei Human Task pausiert.'));
- } else {
- showSuccess(t('Workflow gestartet'));
- }
- await load();
- } else {
- showError(result?.error || t('Ausführung fehlgeschlagen'));
- }
- } catch (e: unknown) {
- const msg =
- (e as { message?: string })?.message ?? t('Ausführung fehlgeschlagen');
- showError(msg);
- } finally {
- setExecutingWorkflowId(null);
- setFormStartWorkflow(null);
- }
- }, [
- instanceId,
- formStartWorkflow,
- startFormData,
- request,
- showSuccess,
- showError,
- load,
- t,
- ]);
-
- const openTasks = tasks.filter((task) => task.status === 'pending');
- const completedTasks = tasks.filter((task) => task.status !== 'pending');
-
- if (!instanceId) {
- return (
-
- {t('keine Featureinstanz gefunden')}
-
- );
- }
-
- if (loading) {
- return (
-
-
- {t('lade Tasks')}
-
- );
- }
-
- return (
-
-
-
- {/* Open tasks */}
-
-
- {t('Offene Tasks')}
- {openTasks.length > 0 && {openTasks.length}}
-
- {openTasks.length === 0 ? (
- {t('keine offenen Tasks')}
- ) : (
-
- {openTasks.map((task) => (
- handleComplete(task.id, result)}
- submitting={submitting === task.id}
- showDismiss
- onDismiss={() => handleDismissOpenTask(task.id)}
- dismissing={dismissingTaskId === task.id}
- />
- ))}
-
- )}
-
-
- {/* Completed tasks */}
-
-
- {completedExpanded && (
-
- {completedTasks.length === 0 ? (
- {t('keine erledigten Tasks')}
- ) : (
- completedTasks.map((task) => (
- handleComplete(task.id, result)}
- submitting={submitting === task.id}
- readOnly
- />
- ))
- )}
-
- )}
-
-
- {/* Output – abgeschlossene Workflows mit Ergebnis */}
-
-
- {outputExpanded && (
-
- {completedRuns.length === 0 ? (
-
- {t('Keine abgeschlossenen Workflows. Führen Sie einen Workflow aus (z.B. im Editor), um hier die Ergebnisse zu sehen.')}
-
- ) : (
- completedRuns.map((run) => (
-
- ))
- )}
-
- )}
-
-
-
-
-
-
- setFormStartWorkflow(null)}
- closable={
- !(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
- }
- closeOnEscape={
- !(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
- }
- size="medium"
- footerContent={
-
- }
- >
-
-
-
- );
-};
-
-/** Output card for completed workflow runs – zeigt nur die erstellten Dateien (mit fileId). */
-const OutputCard: React.FC<{
- run: CompletedRun;
- instanceId?: string;
-}> = ({ run }) => {
- const { t } = useLanguage();
- const ts = run.sysModifiedAt ?? run.sysCreatedAt ?? 0;
- const files: Array<{ name: string; fileId: string }> = [];
- const nodeOutputs = run.nodeOutputs ?? {};
- for (const [, out] of Object.entries(nodeOutputs)) {
- if (!out || typeof out !== 'object') continue;
- const o = out as Record;
- const docs = (o.documents ?? o.documentList ?? []) as Array>;
- if (!Array.isArray(docs)) continue;
- for (const d of docs) {
- const fileId = (d.validationMetadata as Record)?.fileId as string | undefined;
- if (fileId) {
- files.push({
- name: String(d.documentName ?? d.fileName ?? t('Datei')),
- fileId,
- });
- }
- }
- }
- return (
-
-
-
- {t('Workflow')}
- {run.workflowLabel || run.workflowId || '—'}
-
-
- {t('Abgeschlossen')}
- {formatTimestamp(ts)}
-
-
- {files.length > 0 ? (
-
- {t('Dateien')}
-
- {files.map((f, j) => (
- -
-
- {f.name}
-
-
- ))}
-
-
- ) : (
- {t('kein Output, z.B. Workflow ohne')}
- )}
-
- );
-};
-
-interface TaskCardProps {
- task: Automation2Task;
- instanceId?: string;
- onSubmit: (result: Record) => void;
- submitting: boolean;
- readOnly?: boolean;
- /** Open-task card: show top-right control to cancel run and remove from list. */
- showDismiss?: boolean;
- onDismiss?: () => void;
- dismissing?: boolean;
-}
-
-const TaskCard: React.FC = ({
- task,
- instanceId,
- onSubmit,
- submitting,
- readOnly = false,
- showDismiss = false,
- onDismiss,
- dismissing = false,
-}) => {
- const { t } = useLanguage();
- const { handleFileUpload } = useFileOperations();
- const [formData, setFormData] = useState>({});
- const [formPopupOpen, setFormPopupOpen] = useState(false);
- const [uploadedFiles, setUploadedFiles] = useState }>>([]);
- const [uploading, setUploading] = useState(false);
- const [uploadError, setUploadError] = useState(null);
- const fileInputRef = useRef(null);
- const config = task.config ?? {};
- const nodeType = task.nodeType;
- const stepLabel = getNodeStepLabel(config);
-
- const inputFormFields: WorkflowRuntimeFormFieldRow[] =
- nodeType === 'input.form'
- ? ((config.fields as WorkflowRuntimeFormFieldRow[]) ?? [])
- : [];
- const inputFormRequiredOk = useWorkflowRuntimeFormRequiredOk(inputFormFields, formData);
-
- useEffect(() => {
- setUploadedFiles([]);
- setUploadError(null);
- }, [task.id]);
-
- const renderInput = () => {
- if (readOnly) return null;
- switch (nodeType) {
- case 'input.form': {
- const formContent = (
-
- );
- return (
- <>
-
- setFormPopupOpen(false)}
- size="medium"
- footerContent={
-
- }
- >
- {formContent}
-
- >
- );
- }
- case 'input.approval':
- return (
-
- {config.title != null && String(config.title) !== '' && {String(config.title)}
}
- {config.description != null && String(config.description) !== '' && {String(config.description)}
}
-
-
-
-
-
- );
- case 'input.comment':
- return (
-
-
- );
- case 'input.selection': {
- const options =
- (config.options as Array<{ value: string; label: string }>) ?? [];
- const multiple = config.multiple as boolean;
- return (
-
- {options.map((o) => (
-
- ))}
-
-
- );
- }
- case 'input.confirmation':
- return (
-
- {(config.question as string) ?? t('Bestätigen?')}
-
-
-
-
-
- );
- case 'input.upload': {
- const acceptStr = getAcceptStringFromConfig(config);
- const maxSizeMB = (config.maxSize as number) ?? 10;
- const allowMultiple = (config.multiple as boolean) ?? false;
- const maxSizeBytes = maxSizeMB * 1024 * 1024;
-
- const handleFileSelect = async (e: React.ChangeEvent) => {
- const files = e.target.files;
- if (!files?.length || !instanceId) return;
- if (!allowMultiple && files.length > 1) {
- setUploadError('Nur eine Datei erlaubt.');
- return;
- }
- setUploadError(null);
- setUploading(true);
- const results: Array<{ id: string; fileName: string; file?: Record }> = [];
- for (let i = 0; i < files.length; i++) {
- const file = files[i];
- if (file.size > maxSizeBytes) {
- setUploadError(`Datei "${file.name}" zu groß (max. ${maxSizeMB} MB).`);
- setUploading(false);
- e.target.value = '';
- return;
- }
- if (acceptStr && acceptStr !== '*' && !fileMatchesAccept(file, acceptStr)) {
- setUploadError(
- `Die Datei „${file.name}“ hat ein nicht erlaubtes Format. Bitte eine Datei mit passender Endung verwenden (laut Upload-Schritt im Workflow).`,
- );
- setUploading(false);
- e.target.value = '';
- return;
- }
- try {
- const result = await handleFileUpload(
- file,
- task.workflowId ?? undefined,
- instanceId ?? undefined
- );
- if (result?.success && result?.fileData) {
- const fileMeta = result.fileData?.file ?? result.fileData;
- const fileId = fileMeta?.id ?? fileMeta?.fileName;
- if (fileId) {
- results.push({
- id: fileId,
- fileName: fileMeta?.fileName ?? file.name,
- file: fileMeta,
- });
- }
- } else if (result?.error) {
- setUploadError(result.error);
- setUploading(false);
- e.target.value = '';
- return;
- }
- } catch (err: unknown) {
- const msg = (err as { response?: { data?: { detail?: string } }; message?: string })?.response?.data?.detail ?? (err as Error)?.message ?? t('Upload fehlgeschlagen');
- setUploadError(msg);
- setUploading(false);
- e.target.value = '';
- return;
- }
- }
- setUploadedFiles((prev) => (allowMultiple ? [...prev, ...results] : results));
- setUploading(false);
- e.target.value = '';
- };
-
- const handleSubmitUpload = () => {
- if (uploadedFiles.length === 0) {
- setUploadError(t('Bitte mindestens eine Datei hochladen.'));
- return;
- }
- const file = uploadedFiles[0]?.file ?? { id: uploadedFiles[0]?.id, fileName: uploadedFiles[0]?.fileName };
- const files = uploadedFiles.map((u) => u.file ?? { id: u.id, fileName: u.fileName });
- const fileIds = uploadedFiles.map((u) => u.id);
- onSubmit({
- file,
- files,
- fileIds,
- });
- };
-
- return (
-
-
-