diff --git a/src/components/FlowEditor/editor/CanvasHeader.tsx b/src/components/FlowEditor/editor/CanvasHeader.tsx
index a7ea722..3ffe702 100644
--- a/src/components/FlowEditor/editor/CanvasHeader.tsx
+++ b/src/components/FlowEditor/editor/CanvasHeader.tsx
@@ -3,7 +3,7 @@
*/
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
-import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown } from 'react-icons/fa';
+import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown, FaSitemap } from 'react-icons/fa';
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
@@ -34,6 +34,7 @@ interface CanvasHeaderProps {
templateSaving?: boolean;
onNewFromTemplate?: () => void;
onWorkflowRename?: (workflowId: string, newName: string) => void;
+ onAutoLayout?: () => void;
}
function _getStatusBadge(t: (key: string) => string): Record
{
@@ -68,6 +69,7 @@ export const CanvasHeader: React.FC = ({ workflows,
templateSaving,
onNewFromTemplate,
onWorkflowRename,
+ onAutoLayout,
}) => {
const { t } = useLanguage();
const statusBadge = _getStatusBadge(t);
@@ -216,6 +218,19 @@ export const CanvasHeader: React.FC = ({ workflows,
{saving ? : t('Speichern')}
+ {onAutoLayout && (
+
+ )}
+
{/* Save as template */}
{currentWorkflowId && onSaveAsTemplate && (
diff --git a/src/components/FlowEditor/editor/FlowCanvas.tsx b/src/components/FlowEditor/editor/FlowCanvas.tsx
index c579f21..3589a2d 100644
--- a/src/components/FlowEditor/editor/FlowCanvas.tsx
+++ b/src/components/FlowEditor/editor/FlowCanvas.tsx
@@ -37,6 +37,75 @@ const NODE_WIDTH = 200;
const NODE_HEIGHT = 72;
const HANDLE_SIZE = 12;
const HANDLE_OFFSET = HANDLE_SIZE / 2;
+const LAYOUT_V_GAP = 80;
+const LAYOUT_H_GAP = 60;
+
+/**
+ * Topological-sort based auto-layout: arranges nodes top-to-bottom in layers.
+ * Disconnected nodes are appended as extra roots.
+ */
+export function computeAutoLayout(
+ nodes: CanvasNode[],
+ connections: CanvasConnection[],
+): CanvasNode[] {
+ if (nodes.length === 0) return nodes;
+
+ const inDegree = new Map
();
+ const children = new Map();
+ for (const n of nodes) {
+ inDegree.set(n.id, 0);
+ children.set(n.id, []);
+ }
+ for (const c of connections) {
+ inDegree.set(c.targetId, (inDegree.get(c.targetId) ?? 0) + 1);
+ children.get(c.sourceId)?.push(c.targetId);
+ }
+
+ const layers: string[][] = [];
+ const layerOf = new Map();
+ const queue: string[] = [];
+ for (const n of nodes) {
+ if ((inDegree.get(n.id) ?? 0) === 0) queue.push(n.id);
+ }
+
+ while (queue.length > 0) {
+ const batch: string[] = [...queue];
+ queue.length = 0;
+ const layerIdx = layers.length;
+ layers.push(batch);
+ for (const id of batch) {
+ layerOf.set(id, layerIdx);
+ for (const childId of children.get(id) ?? []) {
+ const deg = (inDegree.get(childId) ?? 1) - 1;
+ inDegree.set(childId, deg);
+ if (deg === 0) queue.push(childId);
+ }
+ }
+ }
+
+ const placed = new Set(layerOf.keys());
+ for (const n of nodes) {
+ if (!placed.has(n.id)) {
+ const layerIdx = layers.length;
+ layers.push([n.id]);
+ layerOf.set(n.id, layerIdx);
+ }
+ }
+
+ const startX = 40;
+ const startY = 40;
+
+ return nodes.map((n) => {
+ const layer = layerOf.get(n.id) ?? 0;
+ const siblings = layers[layer];
+ const idxInLayer = siblings.indexOf(n.id);
+ return {
+ ...n,
+ x: startX + idxInLayer * (NODE_WIDTH + LAYOUT_H_GAP),
+ y: startY + layer * (NODE_HEIGHT + LAYOUT_V_GAP),
+ };
+ });
+}
/** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */
function _checkConnectionCompatibility(
@@ -164,26 +233,16 @@ export const FlowCanvas: React.FC = ({ nodes,
const w = NODE_WIDTH;
const h = NODE_HEIGHT;
- const centerY = node.y + h / 2;
+ const centerX = node.x + w / 2;
if (isOutput) {
- if (ioCount === 1) return { x: node.x + w, y: centerY, side: 'right' };
- if (ioCount === 2) {
- return ioIndex === 0
- ? { x: node.x + w, y: node.y + h / 3, side: 'right' }
- : { x: node.x + w, y: node.y + (2 * h) / 3, side: 'right' };
- }
- const step = h / (ioCount + 1);
- return { x: node.x + w, y: node.y + step * (ioIndex + 1), side: 'right' };
+ if (ioCount === 1) return { x: centerX, y: node.y + h, side: 'bottom' };
+ const step = w / (ioCount + 1);
+ return { x: node.x + step * (ioIndex + 1), y: node.y + h, side: 'bottom' };
} else {
- if (ioCount === 1) return { x: node.x, y: centerY, side: 'left' };
- if (ioCount === 2) {
- return ioIndex === 0
- ? { x: node.x, y: node.y + h / 3, side: 'left' }
- : { x: node.x, y: node.y + (2 * h) / 3, side: 'left' };
- }
- const step = h / (ioCount + 1);
- return { x: node.x, y: node.y + step * (ioIndex + 1), side: 'left' };
+ if (ioCount === 1) return { x: centerX, y: node.y, side: 'top' };
+ const step = w / (ioCount + 1);
+ return { x: node.x + step * (ioIndex + 1), y: node.y, side: 'top' };
}
},
[]
@@ -639,8 +698,8 @@ export const FlowCanvas: React.FC = ({ nodes,
if (!srcNode || !tgtNode) return null;
const src = getHandlePosition(srcNode, c.sourceHandle);
const tgt = getHandlePosition(tgtNode, c.targetHandle);
- const dx = tgt.x - src.x;
- const pathD = `M ${src.x} ${src.y} C ${src.x + Math.abs(dx) / 2} ${src.y}, ${tgt.x - Math.abs(dx) / 2} ${tgt.y}, ${tgt.x} ${tgt.y}`;
+ const dy = tgt.y - src.y;
+ const pathD = `M ${src.x} ${src.y} C ${src.x} ${src.y + Math.abs(dy) / 2}, ${tgt.x} ${tgt.y - Math.abs(dy) / 2}, ${tgt.x} ${tgt.y}`;
const isSelected = selectedConnectionId === c.id;
const isWarning = connectionWarnings[c.id];
const strokeColor = isSelected
@@ -756,12 +815,12 @@ export const FlowCanvas: React.FC = ({ nodes,
key={index}
className={styles.handleWrapper}
style={{
- left: pos.side === 'left' ? -HANDLE_OFFSET : undefined,
- right: pos.side === 'right' ? -HANDLE_OFFSET : undefined,
- top: pos.y - node.y - HANDLE_OFFSET,
+ top: pos.side === 'top' ? -HANDLE_OFFSET : undefined,
+ bottom: pos.side === 'bottom' ? -HANDLE_OFFSET : undefined,
+ left: pos.x - node.x - HANDLE_OFFSET,
}}
>
- {outputLabel && pos.side === 'right' && (
+ {outputLabel && pos.side === 'bottom' && (
{outputLabel}
)}
= ({ nodes,
: undefined)
}
/>
- {outputLabel && pos.side === 'left' && (
+ {outputLabel && pos.side === 'top' && (
{outputLabel}
)}
diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx
index d229ffe..00e9110 100644
--- a/src/pages/AutomationsDashboardPage.tsx
+++ b/src/pages/AutomationsDashboardPage.tsx
@@ -18,6 +18,7 @@ import { formatUnixTimestamp } from '../utils/time';
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
import api from '../api';
import { useLanguage } from '../providers/language/LanguageContext';
+import { useNavigation, type DynamicBlock } from '../hooks/useNavigation';
import styles from './admin/Admin.module.css';
// ---------------------------------------------------------------------------
@@ -53,6 +54,7 @@ interface SystemWorkflow {
id: string;
mandateId: string;
featureInstanceId: string;
+ featureCode?: string;
label: string;
active: boolean;
isRunning?: boolean;
@@ -72,6 +74,43 @@ interface SystemWorkflow {
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;
+}
+
function _formatTs(ts?: number): string {
if (ts == null || ts <= 0) return '—';
const sec = ts < 1e12 ? ts : ts / 1000;
@@ -664,6 +703,7 @@ const _WorkflowsTab: React.FC = () => {
const { request } = useApiRequest();
const { showSuccess, showError } = useToast();
const { prompt: promptInput, PromptDialog } = usePrompt();
+ const { dynamicBlock } = useNavigation();
const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(true);
@@ -718,10 +758,19 @@ const _WorkflowsTab: React.FC = () => {
}, [hasRunningWorkflows, _load]);
const _handleEdit = useCallback((row: SystemWorkflow) => {
- if (!row.mandateId || !row.featureInstanceId) return;
- const fc = (row as any).featureCode || 'graphicalEditor';
- navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`);
- }, [navigate]);
+ 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 {
diff --git a/src/pages/ComplianceAuditPage.tsx b/src/pages/ComplianceAuditPage.tsx
index 3d18ac9..10a6a52 100644
--- a/src/pages/ComplianceAuditPage.tsx
+++ b/src/pages/ComplianceAuditPage.tsx
@@ -133,7 +133,7 @@ export const ComplianceAuditPage: React.FC = () => {
const [mandates, setMandates] = useState([]);
const [mandatesLoading, setMandatesLoading] = useState(true);
const [selectedMandateId, setSelectedMandateId] = useState(null);
- const [activeTab, setActiveTab] = useState('ai-log');
+ const [activeTab, setActiveTab] = useState('audit-log');
// ── Tab A: AI-Log state ──
const [aiEntries, setAiEntries] = useState([]);
@@ -193,8 +193,13 @@ export const ComplianceAuditPage: React.FC = () => {
const pageSize = paginationParams?.pageSize ?? _AI_LOG_PAGE_SIZE;
const offset = (page - 1) * pageSize;
+ const params: any = { limit: pageSize, offset };
+ if (paginationParams?.sort?.length) params.sort = JSON.stringify(paginationParams.sort);
+ if (paginationParams?.filters && Object.keys(paginationParams.filters).length) params.filters = JSON.stringify(paginationParams.filters);
+ if (paginationParams?.search) params.search = paginationParams.search;
+
const { data } = await api.get('/api/audit/ai-log', {
- params: { limit: pageSize, offset },
+ params,
headers: _mandateHeaders(),
});
const items: any[] = data?.items ?? [];
@@ -220,8 +225,13 @@ export const ComplianceAuditPage: React.FC = () => {
const pageSize = paginationParams?.pageSize ?? _AUDIT_LOG_PAGE_SIZE;
const offset = (page - 1) * pageSize;
+ const params: any = { limit: pageSize, offset };
+ if (paginationParams?.sort?.length) params.sort = JSON.stringify(paginationParams.sort);
+ if (paginationParams?.filters && Object.keys(paginationParams.filters).length) params.filters = JSON.stringify(paginationParams.filters);
+ if (paginationParams?.search) params.search = paginationParams.search;
+
const { data } = await api.get('/api/audit/log', {
- params: { limit: pageSize, offset },
+ params,
headers: _mandateHeaders(),
});
const items: any[] = data?.items ?? [];
@@ -262,8 +272,13 @@ export const ComplianceAuditPage: React.FC = () => {
const pageSize = paginationParams?.pageSize ?? _NEUT_PAGE_SIZE;
const offset = (page - 1) * pageSize;
+ const neutParams: any = { limit: pageSize, offset };
+ if (paginationParams?.sort?.length) neutParams.sort = JSON.stringify(paginationParams.sort);
+ if (paginationParams?.filters && Object.keys(paginationParams.filters).length) neutParams.filters = JSON.stringify(paginationParams.filters);
+ if (paginationParams?.search) neutParams.search = paginationParams.search;
+
const { data } = await api.get('/api/audit/neutralization-mappings', {
- params: { limit: pageSize, offset },
+ params: neutParams,
headers: _mandateHeaders(),
});
const items: any[] = data?.items ?? [];
@@ -410,8 +425,8 @@ export const ComplianceAuditPage: React.FC = () => {
formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'),
},
{
- key: 'featureCode', label: t('Feature'), type: 'text' as any, sortable: true, filterable: true, width: 130,
- formatter: (val: any, row: any) => row?.instanceLabel || val || '–',
+ key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
+ formatter: (val: any, row: any) => val || row?.featureCode || '–',
},
{ key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 },
{
@@ -467,12 +482,12 @@ export const ComplianceAuditPage: React.FC = () => {
{ key: 'originalText', label: t('Originaltext'), type: 'text' as any, sortable: true, searchable: true, width: 240 },
{ key: 'patternType', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
{
- key: 'userId', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140,
- formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–',
+ key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140,
+ formatter: (val: any, row: any) => val || (row?.userId ? String(row.userId).slice(0, 8) + '…' : '–'),
},
{
- key: 'featureInstanceId', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
- formatter: (val: any) => val || '–',
+ key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
+ formatter: (val: any, row: any) => val || (row?.featureInstanceId ? String(row.featureInstanceId).slice(0, 8) + '…' : '–'),
},
{
key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140,
@@ -480,26 +495,46 @@ export const ComplianceAuditPage: React.FC = () => {
},
], [t]);
+ // ── fetchFilterValues for autofilter dropdowns ──
+
+ const _makeFetchFilterValues = useCallback(
+ (endpoint: string) => async (columnKey: string, crossFilters?: Record) => {
+ if (!selectedMandateId) return [];
+ try {
+ const params: any = { mode: 'filterValues', column: columnKey };
+ if (crossFilters && Object.keys(crossFilters).length) {
+ params.filters = JSON.stringify(crossFilters);
+ }
+ const { data } = await api.get(endpoint, { params, headers: _mandateHeaders() });
+ return Array.isArray(data) ? data : [];
+ } catch { return []; }
+ },
+ [selectedMandateId], // eslint-disable-line react-hooks/exhaustive-deps
+ );
+
// ── hookData for FormGeneratorTable ──
const aiLogHookData = useMemo(() => ({
refetch: _loadAiLog,
pagination: aiPagination,
- }), [_loadAiLog, aiPagination]);
+ fetchFilterValues: _makeFetchFilterValues('/api/audit/ai-log'),
+ }), [_loadAiLog, aiPagination, _makeFetchFilterValues]);
const auditLogHookData = useMemo(() => ({
refetch: _loadAuditLog,
pagination: auditPagination,
- }), [_loadAuditLog, auditPagination]);
+ fetchFilterValues: _makeFetchFilterValues('/api/audit/log'),
+ }), [_loadAuditLog, auditPagination, _makeFetchFilterValues]);
const neutHookData = useMemo(() => ({
refetch: _loadNeutMappings,
pagination: neutPagination,
- }), [_loadNeutMappings, neutPagination]);
+ fetchFilterValues: _makeFetchFilterValues('/api/audit/neutralization-mappings'),
+ }), [_loadNeutMappings, neutPagination, _makeFetchFilterValues]);
// ── Render ──
- const _tabs: TabId[] = ['ai-log', 'audit-log', 'stats', 'neutralization'];
+ const _tabs: TabId[] = ['audit-log', 'ai-log', 'neutralization', 'stats'];
return (
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 11c381d..0d3e523 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -17,15 +17,14 @@ import styles from './Settings.module.css';
// TYPES
// =============================================================================
-type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy';
+type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy';
function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] {
return [
- { key: 'profile', label: t('Tab Profil') },
- { key: 'appearance', label: t('Tab Darstellung') },
- { key: 'voice', label: t('Tab Stimme & Sprache') },
- { key: 'neutralization', label: t('Tab Neutralisierung') },
- { key: 'privacy', label: t('Tab Datenschutz') },
+ { key: 'profile', label: t('Profil') },
+ { key: 'appearance', label: t('Darstellung') },
+ { key: 'voice', label: t('Stimme & Sprache') },
+ { key: 'privacy', label: t('Datenschutz') },
];
}
@@ -563,8 +562,6 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'voice' && }
- {activeTab === 'neutralization' && }
-
{activeTab === 'privacy' && (