Merge pull request #35 from valueonag/feat/demo-system-readieness
fixes from demo1: compliance ui fgtable issues, nodes vertical, nodes…
This commit is contained in:
commit
771a86e989
6 changed files with 212 additions and 52 deletions
|
|
@ -31,7 +31,7 @@ import {
|
||||||
type AutoVersion,
|
type AutoVersion,
|
||||||
type AutoTemplateScope,
|
type AutoTemplateScope,
|
||||||
} from '../../../api/workflowApi';
|
} 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 { NodeConfigPanel } from './NodeConfigPanel';
|
||||||
import { NodeSidebar } from './NodeSidebar';
|
import { NodeSidebar } from './NodeSidebar';
|
||||||
import { CanvasHeader } from './CanvasHeader';
|
import { CanvasHeader } from './CanvasHeader';
|
||||||
|
|
@ -587,6 +587,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
}
|
}
|
||||||
}, [request, instanceId]);
|
}, [request, instanceId]);
|
||||||
|
|
||||||
|
const handleAutoLayout = useCallback(() => {
|
||||||
|
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
|
||||||
|
}, [canvasConnections]);
|
||||||
|
|
||||||
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
|
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
|
||||||
|
|
||||||
const renderSidebar = () => {
|
const renderSidebar = () => {
|
||||||
|
|
@ -708,6 +712,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
templateSaving={templateSaving}
|
templateSaving={templateSaving}
|
||||||
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
||||||
onWorkflowRename={handleWorkflowRename}
|
onWorkflowRename={handleWorkflowRename}
|
||||||
|
onAutoLayout={handleAutoLayout}
|
||||||
/>
|
/>
|
||||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
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 type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
|
@ -34,6 +34,7 @@ interface CanvasHeaderProps {
|
||||||
templateSaving?: boolean;
|
templateSaving?: boolean;
|
||||||
onNewFromTemplate?: () => void;
|
onNewFromTemplate?: () => void;
|
||||||
onWorkflowRename?: (workflowId: string, newName: string) => void;
|
onWorkflowRename?: (workflowId: string, newName: string) => void;
|
||||||
|
onAutoLayout?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
||||||
|
|
@ -68,6 +69,7 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
templateSaving,
|
templateSaving,
|
||||||
onNewFromTemplate,
|
onNewFromTemplate,
|
||||||
onWorkflowRename,
|
onWorkflowRename,
|
||||||
|
onAutoLayout,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const statusBadge = _getStatusBadge(t);
|
const statusBadge = _getStatusBadge(t);
|
||||||
|
|
@ -216,6 +218,19 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
|
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
|
||||||
</button>
|
</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 */}
|
{/* Save as template */}
|
||||||
{currentWorkflowId && onSaveAsTemplate && (
|
{currentWorkflowId && onSaveAsTemplate && (
|
||||||
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
|
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,75 @@ const NODE_WIDTH = 200;
|
||||||
const NODE_HEIGHT = 72;
|
const NODE_HEIGHT = 72;
|
||||||
const HANDLE_SIZE = 12;
|
const HANDLE_SIZE = 12;
|
||||||
const HANDLE_OFFSET = HANDLE_SIZE / 2;
|
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' */
|
/** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */
|
||||||
function _checkConnectionCompatibility(
|
function _checkConnectionCompatibility(
|
||||||
|
|
@ -164,26 +233,16 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
|
|
||||||
const w = NODE_WIDTH;
|
const w = NODE_WIDTH;
|
||||||
const h = NODE_HEIGHT;
|
const h = NODE_HEIGHT;
|
||||||
const centerY = node.y + h / 2;
|
const centerX = node.x + w / 2;
|
||||||
|
|
||||||
if (isOutput) {
|
if (isOutput) {
|
||||||
if (ioCount === 1) return { x: node.x + w, y: centerY, side: 'right' };
|
if (ioCount === 1) return { x: centerX, y: node.y + h, side: 'bottom' };
|
||||||
if (ioCount === 2) {
|
const step = w / (ioCount + 1);
|
||||||
return ioIndex === 0
|
return { x: node.x + step * (ioIndex + 1), y: node.y + h, side: 'bottom' };
|
||||||
? { 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' };
|
|
||||||
} else {
|
} else {
|
||||||
if (ioCount === 1) return { x: node.x, y: centerY, side: 'left' };
|
if (ioCount === 1) return { x: centerX, y: node.y, side: 'top' };
|
||||||
if (ioCount === 2) {
|
const step = w / (ioCount + 1);
|
||||||
return ioIndex === 0
|
return { x: node.x + step * (ioIndex + 1), y: node.y, side: 'top' };
|
||||||
? { 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' };
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
|
|
@ -639,8 +698,8 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
if (!srcNode || !tgtNode) return null;
|
if (!srcNode || !tgtNode) return null;
|
||||||
const src = getHandlePosition(srcNode, c.sourceHandle);
|
const src = getHandlePosition(srcNode, c.sourceHandle);
|
||||||
const tgt = getHandlePosition(tgtNode, c.targetHandle);
|
const tgt = getHandlePosition(tgtNode, c.targetHandle);
|
||||||
const dx = tgt.x - src.x;
|
const dy = tgt.y - src.y;
|
||||||
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 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 isSelected = selectedConnectionId === c.id;
|
||||||
const isWarning = connectionWarnings[c.id];
|
const isWarning = connectionWarnings[c.id];
|
||||||
const strokeColor = isSelected
|
const strokeColor = isSelected
|
||||||
|
|
@ -756,12 +815,12 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
key={index}
|
key={index}
|
||||||
className={styles.handleWrapper}
|
className={styles.handleWrapper}
|
||||||
style={{
|
style={{
|
||||||
left: pos.side === 'left' ? -HANDLE_OFFSET : undefined,
|
top: pos.side === 'top' ? -HANDLE_OFFSET : undefined,
|
||||||
right: pos.side === 'right' ? -HANDLE_OFFSET : undefined,
|
bottom: pos.side === 'bottom' ? -HANDLE_OFFSET : undefined,
|
||||||
top: pos.y - node.y - HANDLE_OFFSET,
|
left: pos.x - node.x - HANDLE_OFFSET,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{outputLabel && pos.side === 'right' && (
|
{outputLabel && pos.side === 'bottom' && (
|
||||||
<span className={styles.handleLabel}>{outputLabel}</span>
|
<span className={styles.handleLabel}>{outputLabel}</span>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
|
@ -778,7 +837,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
|
||||||
: undefined)
|
: undefined)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{outputLabel && pos.side === 'left' && (
|
{outputLabel && pos.side === 'top' && (
|
||||||
<span className={styles.handleLabel}>{outputLabel}</span>
|
<span className={styles.handleLabel}>{outputLabel}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { formatUnixTimestamp } from '../utils/time';
|
||||||
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
|
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
import { useNavigation, type DynamicBlock } from '../hooks/useNavigation';
|
||||||
import styles from './admin/Admin.module.css';
|
import styles from './admin/Admin.module.css';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -53,6 +54,7 @@ interface SystemWorkflow {
|
||||||
id: string;
|
id: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
featureInstanceId: string;
|
featureInstanceId: string;
|
||||||
|
featureCode?: string;
|
||||||
label: string;
|
label: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
isRunning?: boolean;
|
isRunning?: boolean;
|
||||||
|
|
@ -72,6 +74,43 @@ interface SystemWorkflow {
|
||||||
graph?: Record<string, any>;
|
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 {
|
function _formatTs(ts?: number): string {
|
||||||
if (ts == null || ts <= 0) return '—';
|
if (ts == null || ts <= 0) return '—';
|
||||||
const sec = ts < 1e12 ? ts : ts / 1000;
|
const sec = ts < 1e12 ? ts : ts / 1000;
|
||||||
|
|
@ -664,6 +703,7 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||||
|
const { dynamicBlock } = useNavigation();
|
||||||
|
|
||||||
const [workflows, setWorkflows] = useState<SystemWorkflow[]>([]);
|
const [workflows, setWorkflows] = useState<SystemWorkflow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -718,10 +758,19 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
}, [hasRunningWorkflows, _load]);
|
}, [hasRunningWorkflows, _load]);
|
||||||
|
|
||||||
const _handleEdit = useCallback((row: SystemWorkflow) => {
|
const _handleEdit = useCallback((row: SystemWorkflow) => {
|
||||||
if (!row.mandateId || !row.featureInstanceId) return;
|
if (!row.mandateId) return;
|
||||||
const fc = (row as any).featureCode || 'graphicalEditor';
|
const fc = row.featureCode || '';
|
||||||
navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`);
|
if (_FEATURES_WITH_EDITOR.has(fc)) {
|
||||||
}, [navigate]);
|
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<boolean> => {
|
const _handleDelete = useCallback(async (workflowId: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
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 ──
|
// ── Tab A: AI-Log state ──
|
||||||
const [aiEntries, setAiEntries] = useState<any[]>([]);
|
const [aiEntries, setAiEntries] = useState<any[]>([]);
|
||||||
|
|
@ -193,8 +193,13 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
const pageSize = paginationParams?.pageSize ?? _AI_LOG_PAGE_SIZE;
|
const pageSize = paginationParams?.pageSize ?? _AI_LOG_PAGE_SIZE;
|
||||||
const offset = (page - 1) * pageSize;
|
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', {
|
const { data } = await api.get('/api/audit/ai-log', {
|
||||||
params: { limit: pageSize, offset },
|
params,
|
||||||
headers: _mandateHeaders(),
|
headers: _mandateHeaders(),
|
||||||
});
|
});
|
||||||
const items: any[] = data?.items ?? [];
|
const items: any[] = data?.items ?? [];
|
||||||
|
|
@ -220,8 +225,13 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
const pageSize = paginationParams?.pageSize ?? _AUDIT_LOG_PAGE_SIZE;
|
const pageSize = paginationParams?.pageSize ?? _AUDIT_LOG_PAGE_SIZE;
|
||||||
const offset = (page - 1) * pageSize;
|
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', {
|
const { data } = await api.get('/api/audit/log', {
|
||||||
params: { limit: pageSize, offset },
|
params,
|
||||||
headers: _mandateHeaders(),
|
headers: _mandateHeaders(),
|
||||||
});
|
});
|
||||||
const items: any[] = data?.items ?? [];
|
const items: any[] = data?.items ?? [];
|
||||||
|
|
@ -262,8 +272,13 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
const pageSize = paginationParams?.pageSize ?? _NEUT_PAGE_SIZE;
|
const pageSize = paginationParams?.pageSize ?? _NEUT_PAGE_SIZE;
|
||||||
const offset = (page - 1) * pageSize;
|
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', {
|
const { data } = await api.get('/api/audit/neutralization-mappings', {
|
||||||
params: { limit: pageSize, offset },
|
params: neutParams,
|
||||||
headers: _mandateHeaders(),
|
headers: _mandateHeaders(),
|
||||||
});
|
});
|
||||||
const items: any[] = data?.items ?? [];
|
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) : '–'),
|
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,
|
key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
|
||||||
formatter: (val: any, row: any) => row?.instanceLabel || val || '–',
|
formatter: (val: any, row: any) => val || row?.featureCode || '–',
|
||||||
},
|
},
|
||||||
{ key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 },
|
{ 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: '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: '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,
|
key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140,
|
||||||
formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–',
|
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,
|
key: 'instanceLabel', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
|
||||||
formatter: (val: any) => val || '–',
|
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,
|
key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140,
|
||||||
|
|
@ -480,26 +495,46 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
},
|
},
|
||||||
], [t]);
|
], [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 ──
|
// ── hookData for FormGeneratorTable ──
|
||||||
|
|
||||||
const aiLogHookData = useMemo(() => ({
|
const aiLogHookData = useMemo(() => ({
|
||||||
refetch: _loadAiLog,
|
refetch: _loadAiLog,
|
||||||
pagination: aiPagination,
|
pagination: aiPagination,
|
||||||
}), [_loadAiLog, aiPagination]);
|
fetchFilterValues: _makeFetchFilterValues('/api/audit/ai-log'),
|
||||||
|
}), [_loadAiLog, aiPagination, _makeFetchFilterValues]);
|
||||||
|
|
||||||
const auditLogHookData = useMemo(() => ({
|
const auditLogHookData = useMemo(() => ({
|
||||||
refetch: _loadAuditLog,
|
refetch: _loadAuditLog,
|
||||||
pagination: auditPagination,
|
pagination: auditPagination,
|
||||||
}), [_loadAuditLog, auditPagination]);
|
fetchFilterValues: _makeFetchFilterValues('/api/audit/log'),
|
||||||
|
}), [_loadAuditLog, auditPagination, _makeFetchFilterValues]);
|
||||||
|
|
||||||
const neutHookData = useMemo(() => ({
|
const neutHookData = useMemo(() => ({
|
||||||
refetch: _loadNeutMappings,
|
refetch: _loadNeutMappings,
|
||||||
pagination: neutPagination,
|
pagination: neutPagination,
|
||||||
}), [_loadNeutMappings, neutPagination]);
|
fetchFilterValues: _makeFetchFilterValues('/api/audit/neutralization-mappings'),
|
||||||
|
}), [_loadNeutMappings, neutPagination, _makeFetchFilterValues]);
|
||||||
|
|
||||||
// ── Render ──
|
// ── Render ──
|
||||||
|
|
||||||
const _tabs: TabId[] = ['ai-log', 'audit-log', 'stats', 'neutralization'];
|
const _tabs: TabId[] = ['audit-log', 'ai-log', 'neutralization', 'stats'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,14 @@ import styles from './Settings.module.css';
|
||||||
// TYPES
|
// TYPES
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy';
|
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy';
|
||||||
|
|
||||||
function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] {
|
function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] {
|
||||||
return [
|
return [
|
||||||
{ key: 'profile', label: t('Tab Profil') },
|
{ key: 'profile', label: t('Profil') },
|
||||||
{ key: 'appearance', label: t('Tab Darstellung') },
|
{ key: 'appearance', label: t('Darstellung') },
|
||||||
{ key: 'voice', label: t('Tab Stimme & Sprache') },
|
{ key: 'voice', label: t('Stimme & Sprache') },
|
||||||
{ key: 'neutralization', label: t('Tab Neutralisierung') },
|
{ key: 'privacy', label: t('Datenschutz') },
|
||||||
{ key: 'privacy', label: t('Tab Datenschutz') },
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -563,8 +562,6 @@ export const SettingsPage: React.FC = () => {
|
||||||
|
|
||||||
{activeTab === 'voice' && <VoiceSettingsTab />}
|
{activeTab === 'voice' && <VoiceSettingsTab />}
|
||||||
|
|
||||||
{activeTab === 'neutralization' && <NeutralizationMappingsTab />}
|
|
||||||
|
|
||||||
{activeTab === 'privacy' && (
|
{activeTab === 'privacy' && (
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h2 className={styles.sectionTitle}>{t('Datenschutz')}</h2>
|
<h2 className={styles.sectionTitle}>{t('Datenschutz')}</h2>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue