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

This commit is contained in:
ValueOn AG 2026-04-14 22:56:22 +02:00
parent 851b509f9e
commit 92f293825f
6 changed files with 212 additions and 52 deletions

View file

@ -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<Automation2FlowEditorProps> = ({ 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<Automation2FlowEditorProps> = ({ in
templateSaving={templateSaving}
onNewFromTemplate={() => setTemplatePickerOpen(true)}
onWorkflowRename={handleWorkflowRename}
onAutoLayout={handleAutoLayout}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>

View file

@ -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<string, { label: string; color: string }> {
@ -68,6 +69,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
templateSaving,
onNewFromTemplate,
onWorkflowRename,
onAutoLayout,
}) => {
const { t } = useLanguage();
const statusBadge = _getStatusBadge(t);
@ -216,6 +218,19 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
</button>
{onAutoLayout && (
<button
type="button"
className={styles.retryButton}
onClick={onAutoLayout}
disabled={!hasNodes}
title={t('Knoten automatisch anordnen')}
>
<FaSitemap style={{ marginRight: '0.4rem' }} />
{t('Anordnen')}
</button>
)}
{/* Save as template */}
{currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>

View file

@ -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<string, number>();
const children = new Map<string, string[]>();
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<string, number>();
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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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<FlowCanvasProps> = ({ 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' && (
<span className={styles.handleLabel}>{outputLabel}</span>
)}
<div
@ -778,7 +837,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
: undefined)
}
/>
{outputLabel && pos.side === 'left' && (
{outputLabel && pos.side === 'top' && (
<span className={styles.handleLabel}>{outputLabel}</span>
)}
</div>

View file

@ -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<string, any>;
}
const _FEATURES_WITH_EDITOR = new Set(['graphicalEditor', 'workspace']);
const _ROLE_PRIORITY: Record<string, number> = { 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<SystemWorkflow[]>([]);
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';
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}`);
}, [navigate]);
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<boolean> => {
try {

View file

@ -133,7 +133,7 @@ export const ComplianceAuditPage: React.FC = () => {
const [mandates, setMandates] = useState<Mandate[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true);
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('ai-log');
const [activeTab, setActiveTab] = useState<TabId>('audit-log');
// ── Tab A: AI-Log state ──
const [aiEntries, setAiEntries] = useState<any[]>([]);
@ -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<string, any>) => {
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 (
<div className={styles.wrap}>

View file

@ -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' && <VoiceSettingsTab />}
{activeTab === 'neutralization' && <NeutralizationMappingsTab />}
{activeTab === 'privacy' && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Datenschutz')}</h2>