(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: '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 (
-
- );
-};
-
-const _INTERNAL_EXTRACT_FILENAME_SUBSTR = 'extracted_content_transient';
-
-/** Hide persisted transient extract JSON from user-facing Workspace file lists */
-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})
-
-
-
- );
-};
-
-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 ;
- }
-
- 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} />
- >
- );
- })()}
-
- );
-};
-
-// ===========================================================================
-// Main page with Tabs (Workflows → Dashboard → Workspace)
-// ===========================================================================
+import React from 'react';
+import { Navigate, useSearchParams } from 'react-router-dom';
export const AutomationsDashboardPage: React.FC = () => {
- const { t } = useLanguage();
const [searchParams] = useSearchParams();
-
- const initialTab = searchParams.get('tab') || 'workflows';
- const initialRunId = searchParams.get('runId') || null;
-
- const [activeTab, setActiveTab] = useState(initialRunId ? 'workspace' : initialTab);
- const [selectedRunId, setSelectedRunId] = useState(initialRunId);
- const [workflowFilter, setWorkflowFilter] = useState(null);
-
- const _handleWorkflowClick = useCallback((workflowId: string) => {
- setWorkflowFilter(workflowId);
- setActiveTab('dashboard');
- }, []);
-
- useEffect(() => {
- if (workflowFilter) setWorkflowFilter(null);
- }, [workflowFilter]);
-
- const _handleRunClick = useCallback((runId: string) => {
- setSelectedRunId(runId);
- setActiveTab('workspace');
- }, []);
-
- const _handleBackFromWorkspace = useCallback(() => {
- setSelectedRunId(null);
- setActiveTab('dashboard');
- }, []);
-
- const tabs = useMemo(() => [
- {
- id: 'workflows',
- label: t('Workflows'),
- content: <_WorkflowsTab onWorkflowClick={_handleWorkflowClick} />,
- },
- {
- id: 'dashboard',
- label: t('Workflow-Durchläufe'),
- content: <_DashboardTab workflowFilter={workflowFilter} onRunClick={_handleRunClick} />,
- },
- {
- id: 'workspace',
- label: t('Durchlauf-Details'),
- content: <_WorkspaceTab runId={selectedRunId} onBack={_handleBackFromWorkspace} />,
- },
- ], [t, _handleWorkflowClick, workflowFilter, _handleRunClick, selectedRunId, _handleBackFromWorkspace]);
-
- return (
-
-
{t('Automatisierung')}
-
-
- );
+ 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/FeatureView.tsx b/src/pages/FeatureView.tsx
index bfe6921..1e6cc2e 100644
--- a/src/pages/FeatureView.tsx
+++ b/src/pages/FeatureView.tsx
@@ -25,10 +25,6 @@ import { TrusteeDataTablesView } from './views/trustee/TrusteeDataTablesView';
// RealEstate Views
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
-// GraphicalEditor Views
-import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage';
-import { GraphicalEditorWorkflowsTasksPage } from './views/graphicalEditor/GraphicalEditorWorkflowsTasksPage';
-import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage';
// Workspace Views
import { WorkspacePage } from './views/workspace/WorkspacePage';
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
@@ -129,11 +125,6 @@ const VIEW_COMPONENTS: Record> = {
dashboard: RealEstatePekView,
'instance-roles': RealEstateInstanceRolesPlaceholder,
},
- graphicalEditor: {
- editor: GraphicalEditorPage,
- 'workflows-tasks': GraphicalEditorWorkflowsTasksPage,
- templates: GraphicalEditorTemplatesPage,
- },
workspace: {
dashboard: WorkspacePage,
editor: WorkspaceEditorPage,
diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx
index ff3077e..baccc97 100644
--- a/src/pages/Store.tsx
+++ b/src/pages/Store.tsx
@@ -5,7 +5,7 @@
*/
import React from 'react';
-import { FaCogs, FaComments, FaHeadset, FaProjectDiagram, FaShieldAlt } from 'react-icons/fa';
+import { FaCogs, FaComments, FaHeadset, FaShieldAlt } from 'react-icons/fa';
import { useLanguage } from '../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
import { useStore, _storeActionKey } from '../hooks/useStore';
@@ -15,7 +15,6 @@ import styles from './Store.module.css';
const FEATURE_ICONS: Record = {
automation: ,
- graphicalEditor: ,
teamsbot: ,
workspace: ,
commcoach: ,
@@ -25,7 +24,6 @@ const FEATURE_ICONS: Record = {
/** Fallback when GET /store/features omits description (German i18n keys). */
const STORE_FEATURE_DESCRIPTION_FALLBACK: Record = {
automation: 'Erstelle und verwalte Automatisierungen, um wiederkehrende Aufgaben effizient zu erledigen.',
- graphicalEditor: 'n8n-style Flow-Automatisierung mit grafischem Editor, RAG und Tools.',
teamsbot: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
workspace: 'Nutze den gemeinsamen AI Workspace: Chats, Tools und Kontext pro Instanz.',
commcoach: 'CommCoach: Kommunikation trainieren mit KI-gestütztem Coaching und Feedback.',
diff --git a/src/pages/WorkflowAutomationPage.tsx b/src/pages/WorkflowAutomationPage.tsx
new file mode 100644
index 0000000..00a4a0a
--- /dev/null
+++ b/src/pages/WorkflowAutomationPage.tsx
@@ -0,0 +1,1565 @@
+/**
+ * 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 }) => (
+
+);
+
+// ===========================================================================
+// 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 (
+
+ );
+};
+
+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})
+
+
+
+ );
+};
+
+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 ;
+ }
+
+ 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/GraphicalEditorTemplatesPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
index 8cb5615..6faf39c 100644
--- a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
+++ b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
@@ -44,7 +44,15 @@ function _formatTs(ts?: number): string {
return time;
}
-export const GraphicalEditorTemplatesPage: React.FC = () => {
+interface GraphicalEditorTemplatesPageProps {
+ persistentInstanceId?: string;
+ persistentMandateId?: string;
+}
+
+export const GraphicalEditorTemplatesPage: React.FC = ({
+ persistentInstanceId,
+ persistentMandateId,
+}) => {
const { t } = useLanguage();
const scopeLabels = useMemo(
@@ -57,8 +65,10 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
[t],
);
- const instanceId = useInstanceId();
- const { mandateId } = useParams<{ mandateId: string }>();
+ const urlInstanceId = useInstanceId();
+ const { mandateId: urlMandateId } = useParams<{ mandateId: string }>();
+ const instanceId = persistentInstanceId || urlInstanceId;
+ const mandateId = persistentMandateId || urlMandateId;
const { request } = useApiRequest();
const navigate = useNavigate();
const { showSuccess, showError } = useToast();
diff --git a/src/types/mandate.ts b/src/types/mandate.ts
index 3ba7f92..f112c36 100644
--- a/src/types/mandate.ts
+++ b/src/types/mandate.ts
@@ -182,113 +182,9 @@ export interface FeatureConfig {
deprecated?: boolean;
}
-// =============================================================================
-// FEATURE REGISTRY (DEPRECATED)
-// =============================================================================
-
-/**
- * @deprecated Since Navigation-API-Konzept implementation.
- *
- * Navigation is now provided by the backend via GET /api/navigation.
- * The backend is the Single Source of Truth for navigation structure.
- *
- * Icon mapping is now handled by src/config/pageRegistry.ts using uiComponent codes.
- *
- * This registry is kept for backward compatibility with existing code that may
- * still reference it. It will be removed in a future version.
- *
- * TODO: Remove after all references are migrated to use backend navigation.
- */
-export const FEATURE_REGISTRY: Record = {
- trustee: {
- code: 'trustee',
- label: 'Treuhand',
- icon: 'briefcase',
- views: [
- { code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
- { code: 'data-tables', label: 'Daten-Tabellen', path: 'data-tables' },
- { code: 'position-documents', label: 'Zuordnungen', path: 'position-documents' },
- { code: 'import-process', label: 'Import & Verarbeitung', path: 'import-process' },
- { code: 'instance-roles', label: 'Rollen & Rechte', path: 'instance-roles', adminOnly: true },
- { code: 'settings', label: 'Buchhaltungseinstellungen', path: 'settings' },
- ]
- },
- chatworkflow: {
- code: 'chatworkflow',
- label: 'Workflow',
- icon: 'play_circle',
- views: [
- { code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
- { code: 'runs', label: 'Runs', path: 'runs' },
- { code: 'files', label: 'Dateien', path: 'files' },
- ]
- },
- realestate: {
- code: 'realestate',
- label: 'Immobilien',
- icon: 'home',
- views: [
- { code: 'dashboard', label: 'Karte', path: 'dashboard' },
- { code: 'instance-roles', label: 'Rollen & Rechte', path: 'instance-roles', adminOnly: true },
- ]
- },
- teamsbot: {
- code: 'teamsbot',
- label: 'Teams Bot',
- icon: 'headset_mic',
- views: [
- { code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
- { code: 'assistant', label: 'Assistent', path: 'assistant' },
- { code: 'modules', label: 'Module', path: 'modules' },
- { code: 'sessions', label: 'Live-Session', path: 'sessions' },
- { code: 'settings', label: 'Einstellungen', path: 'settings' },
- ]
- },
- graphicalEditor: {
- code: 'graphicalEditor',
- label: 'Grafischer Editor',
- icon: 'sitemap',
- views: [
- { code: 'editor', label: 'Editor', path: 'editor' },
- { code: 'templates', label: 'Vorlagen', path: 'templates' },
- { code: 'workflows-tasks', label: 'Tasks', path: 'workflows-tasks' },
- { code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
- ]
- },
- neutralization: {
- code: 'neutralization',
- label: 'Neutralisierung',
- icon: 'shield_check',
- views: [
- { code: 'dashboard', label: 'Neutralisierung testen', path: 'playground' },
- { code: 'playground', label: 'Neutralisierung testen', path: 'playground' },
- { code: 'config', label: 'Einstellungen', path: 'config' },
- { code: 'attributes', label: 'Attribute', path: 'attributes' },
- ]
- },
- commcoach: {
- code: 'commcoach',
- label: 'Kommunikations-Coach',
- icon: 'account_voice',
- views: [
- { code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
- { code: 'assistant', label: 'Assistent', path: 'assistant' },
- { code: 'modules', label: 'Module', path: 'modules' },
- { code: 'session', label: 'Session', path: 'session' },
- { code: 'settings', label: 'Einstellungen', path: 'settings' },
- ]
- },
- workspace: {
- code: 'workspace',
- label: 'AI Workspace',
- icon: 'psychology',
- views: [
- { code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
- { code: 'editor', label: 'Editor', path: 'editor' },
- { code: 'settings', label: 'Einstellungen', path: 'settings' },
- ]
- },
-};
+// FEATURE_REGISTRY removed (2026-06-07).
+// Navigation is provided by the backend via GET /api/navigation.
+// Icon mapping is handled by src/config/pageRegistry.ts using uiComponent codes.
// =============================================================================
// HELPERS