From 92f293825ff181dd4ccc5734ac5d720abeda44c9 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 14 Apr 2026 22:56:22 +0200 Subject: [PATCH] fixes from demo1: compliance ui fgtable issues, nodes vertical, nodes editting logic to edit in all editors of a mmandate based on highest level of role --- .../editor/Automation2FlowEditor.tsx | 7 +- .../FlowEditor/editor/CanvasHeader.tsx | 17 ++- .../FlowEditor/editor/FlowCanvas.tsx | 107 ++++++++++++++---- src/pages/AutomationsDashboardPage.tsx | 57 +++++++++- src/pages/ComplianceAuditPage.tsx | 63 ++++++++--- src/pages/Settings.tsx | 13 +-- 6 files changed, 212 insertions(+), 52 deletions(-) diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index de4a245..37a1fdb 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -31,7 +31,7 @@ import { type AutoVersion, type AutoTemplateScope, } from '../../../api/workflowApi'; -import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas'; +import { FlowCanvas, computeAutoLayout, type CanvasNode, type CanvasConnection } from './FlowCanvas'; import { NodeConfigPanel } from './NodeConfigPanel'; import { NodeSidebar } from './NodeSidebar'; import { CanvasHeader } from './CanvasHeader'; @@ -587,6 +587,10 @@ export const Automation2FlowEditor: React.FC = ({ in } }, [request, instanceId]); + const handleAutoLayout = useCallback(() => { + setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections)); + }, [canvasConnections]); + const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]); const renderSidebar = () => { @@ -708,6 +712,7 @@ export const Automation2FlowEditor: React.FC = ({ in templateSaving={templateSaving} onNewFromTemplate={() => setTemplatePickerOpen(true)} onWorkflowRename={handleWorkflowRename} + onAutoLayout={handleAutoLayout} />
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' && (

{t('Datenschutz')}