900 lines
35 KiB
TypeScript
900 lines
35 KiB
TypeScript
/**
|
||
* ComplianceAuditPage — Compliance & AI-Audit dashboard.
|
||
*
|
||
* Tab A: AI Data-Flow Log — FormGeneratorTable + content view modal + download
|
||
* Tab B: Security / GDPR Audit Log — FormGeneratorTable
|
||
* Tab C: Aggregated AI-Audit Statistics with charts
|
||
* Tab D: Neutralization Mappings — FormGeneratorTable + delete
|
||
*/
|
||
|
||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||
import {
|
||
ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid,
|
||
Tooltip, BarChart, Bar, PieChart, Pie, Cell,
|
||
} from 'recharts';
|
||
import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa';
|
||
import api from '../api';
|
||
import { useLanguage } from '../providers/language/LanguageContext';
|
||
import { useUserMandates } from '../hooks/useUserMandates';
|
||
import { useConfirm } from '../hooks/useConfirm';
|
||
import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
|
||
import styles from './ComplianceAuditPage.module.css';
|
||
|
||
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828', '#2e7d32'];
|
||
|
||
const _CATEGORY_COLORS: Record<string, string> = {
|
||
security: '#c62828', gdpr: '#6a1b9a', permission: '#e65100',
|
||
access: '#1565c0', key: '#2e7d32', data: '#00897b',
|
||
};
|
||
|
||
type TabId = 'ai-log' | 'audit-log' | 'stats' | 'neutralization';
|
||
|
||
function _tabLabel(tabId: TabId, t: (k: string) => string): string {
|
||
switch (tabId) {
|
||
case 'ai-log': return t('AI-Datenfluss');
|
||
case 'audit-log': return t('Audit-Log');
|
||
case 'stats': return t('Statistiken');
|
||
case 'neutralization': return t('Neutralisierung');
|
||
default: return tabId;
|
||
}
|
||
}
|
||
|
||
// ─── Placeholder highlighting ───
|
||
|
||
const _PLACEHOLDER_RX = /\[([a-z]+)\.([a-f0-9-]{36})\]/g;
|
||
|
||
const _PH_TYPE_COLORS: Record<string, string> = {
|
||
name: '#7c3aed', email: '#2563eb', phone: '#0891b2',
|
||
address: '#059669', financial: '#d97706', id: '#dc2626',
|
||
logic: '#be185d', company: '#4f46e5', product: '#7c3aed',
|
||
location: '#059669', other: '#6b7280',
|
||
};
|
||
|
||
interface NeutMapping { id: string; originalText: string; patternType: string; }
|
||
|
||
function _renderHighlightedText(
|
||
text: string,
|
||
lookup: Map<string, NeutMapping>,
|
||
): React.ReactNode[] {
|
||
const parts: React.ReactNode[] = [];
|
||
let lastIdx = 0;
|
||
const rx = new RegExp(_PLACEHOLDER_RX.source, 'g');
|
||
let match: RegExpExecArray | null;
|
||
|
||
while ((match = rx.exec(text)) !== null) {
|
||
if (match.index > lastIdx) {
|
||
parts.push(<span key={`t-${lastIdx}`}>{text.slice(lastIdx, match.index)}</span>);
|
||
}
|
||
const phType = match[1];
|
||
const phId = match[2];
|
||
const fullPh = match[0];
|
||
const mapping = lookup.get(phId);
|
||
const color = _PH_TYPE_COLORS[phType] || _PH_TYPE_COLORS.other;
|
||
parts.push(
|
||
<span
|
||
key={`ph-${match.index}`}
|
||
title={mapping ? `${mapping.originalText} (${phType})` : phType}
|
||
style={{
|
||
background: color + '18',
|
||
color,
|
||
border: `1px solid ${color}40`,
|
||
borderRadius: 4,
|
||
padding: '1px 4px',
|
||
fontFamily: 'monospace',
|
||
fontSize: '0.78rem',
|
||
cursor: 'help',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{fullPh}
|
||
</span>,
|
||
);
|
||
lastIdx = match.index + match[0].length;
|
||
}
|
||
if (lastIdx < text.length) {
|
||
parts.push(<span key={`t-${lastIdx}`}>{text.slice(lastIdx)}</span>);
|
||
}
|
||
return parts;
|
||
}
|
||
|
||
// ─── Shared types ───
|
||
|
||
interface AuditStats {
|
||
totalCalls: number;
|
||
timeRangeDays: number;
|
||
callsPerDay: Array<{ date: string; calls: number }>;
|
||
costPerDay: Array<{ date: string; cost: number }>;
|
||
callsByModel: Record<string, number>;
|
||
callsByFeature: Record<string, number>;
|
||
topUsers: Record<string, number>;
|
||
neutralizationPercent: number;
|
||
}
|
||
|
||
interface Mandate { id: string; name?: string; label?: string; }
|
||
|
||
interface ContentModalData {
|
||
row: any;
|
||
contentInputFull?: string;
|
||
contentOutputFull?: string;
|
||
contentInputPreview?: string;
|
||
contentOutputPreview?: string;
|
||
neutralizationMappings: NeutMapping[];
|
||
}
|
||
|
||
const _AI_LOG_PAGE_SIZE = 50;
|
||
const _AUDIT_LOG_PAGE_SIZE = 100;
|
||
const _NEUT_PAGE_SIZE = 100;
|
||
|
||
export const ComplianceAuditPage: React.FC = () => {
|
||
const { t } = useLanguage();
|
||
const { fetchMandates } = useUserMandates();
|
||
const { confirm, ConfirmDialog } = useConfirm();
|
||
|
||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
||
const [activeTab, setActiveTab] = useState<TabId>('audit-log');
|
||
|
||
// ── Tab A: AI-Log state ──
|
||
const [aiEntries, setAiEntries] = useState<any[]>([]);
|
||
const [aiPagination, setAiPagination] = useState<any>(undefined);
|
||
const [aiLoading, setAiLoading] = useState(false);
|
||
|
||
// ── Tab B: Audit-Log state ──
|
||
const [auditEntries, setAuditEntries] = useState<any[]>([]);
|
||
const [auditPagination, setAuditPagination] = useState<any>(undefined);
|
||
const [auditLoading, setAuditLoading] = useState(false);
|
||
|
||
// ── Tab C state ──
|
||
const [stats, setStats] = useState<AuditStats | null>(null);
|
||
const [statsLoading, setStatsLoading] = useState(false);
|
||
const [statsRange, setStatsRange] = useState(30);
|
||
|
||
// ── Tab D: Neutralization Mappings state ──
|
||
const [neutEntries, setNeutEntries] = useState<any[]>([]);
|
||
const [neutPagination, setNeutPagination] = useState<any>(undefined);
|
||
const [neutLoading, setNeutLoading] = useState(false);
|
||
|
||
// ── Content View Modal state ──
|
||
const [contentModal, setContentModal] = useState<ContentModalData | null>(null);
|
||
const [contentModalLoading, setContentModalLoading] = useState(false);
|
||
const [contentModalTab, setContentModalTab] = useState<'input' | 'output'>('input');
|
||
|
||
// ── Mandate loader ──
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
setMandatesLoading(true);
|
||
try {
|
||
const data = await fetchMandates();
|
||
if (!cancelled) {
|
||
const list = Array.isArray(data) ? data : [];
|
||
setMandates(list);
|
||
if (list.length === 1) setSelectedMandateId(list[0].id);
|
||
}
|
||
} catch { /* */ }
|
||
finally { if (!cancelled) setMandatesLoading(false); }
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [fetchMandates]);
|
||
|
||
function _mandateHeaders(): Record<string, string> {
|
||
return selectedMandateId ? { 'X-Mandate-Id': selectedMandateId } : {};
|
||
}
|
||
|
||
// ── Tab A loader ──
|
||
|
||
const _loadAiLog = useCallback(async (paginationParams?: any) => {
|
||
if (!selectedMandateId) return;
|
||
setAiLoading(true);
|
||
try {
|
||
const page = paginationParams?.page ?? 1;
|
||
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,
|
||
headers: _mandateHeaders(),
|
||
});
|
||
const items: any[] = data?.items ?? [];
|
||
const totalItems = data?.totalItems ?? 0;
|
||
setAiEntries(items);
|
||
setAiPagination({
|
||
currentPage: page,
|
||
pageSize,
|
||
totalItems,
|
||
totalPages: Math.max(1, Math.ceil(totalItems / pageSize)),
|
||
});
|
||
} catch { /* */ }
|
||
finally { setAiLoading(false); }
|
||
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// ── Tab B loader ──
|
||
|
||
const _loadAuditLog = useCallback(async (paginationParams?: any) => {
|
||
if (!selectedMandateId) return;
|
||
setAuditLoading(true);
|
||
try {
|
||
const page = paginationParams?.page ?? 1;
|
||
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,
|
||
headers: _mandateHeaders(),
|
||
});
|
||
const items: any[] = data?.items ?? [];
|
||
const totalItems = data?.totalItems ?? items.length;
|
||
setAuditEntries(items);
|
||
setAuditPagination({
|
||
currentPage: page,
|
||
pageSize,
|
||
totalItems,
|
||
totalPages: Math.max(1, Math.ceil(totalItems / pageSize)),
|
||
});
|
||
} catch { /* */ }
|
||
finally { setAuditLoading(false); }
|
||
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// ── Tab C loader ──
|
||
|
||
const _loadStats = useCallback(async (days = 30) => {
|
||
if (!selectedMandateId) return;
|
||
setStatsLoading(true);
|
||
try {
|
||
const { data } = await api.get('/api/audit/stats', {
|
||
params: { timeRange: days },
|
||
headers: _mandateHeaders(),
|
||
});
|
||
setStats(data ?? null);
|
||
} catch { /* */ }
|
||
finally { setStatsLoading(false); }
|
||
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// ── Tab D loader ──
|
||
|
||
const _loadNeutMappings = useCallback(async (paginationParams?: any) => {
|
||
if (!selectedMandateId) return;
|
||
setNeutLoading(true);
|
||
try {
|
||
const page = paginationParams?.page ?? 1;
|
||
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: neutParams,
|
||
headers: _mandateHeaders(),
|
||
});
|
||
const items: any[] = data?.items ?? [];
|
||
const totalItems = data?.totalItems ?? 0;
|
||
setNeutEntries(items);
|
||
setNeutPagination({
|
||
currentPage: page,
|
||
pageSize,
|
||
totalItems,
|
||
totalPages: Math.max(1, Math.ceil(totalItems / pageSize)),
|
||
});
|
||
} catch { /* */ }
|
||
finally { setNeutLoading(false); }
|
||
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const _handleDeleteMapping = useCallback(async (row: any) => {
|
||
if (!selectedMandateId || !row?.id) return;
|
||
const ok = await confirm(
|
||
t('Soll diese Zuordnung wirklich gelöscht werden?'),
|
||
{ confirmLabel: t('Löschen'), variant: 'danger' },
|
||
);
|
||
if (!ok) return;
|
||
try {
|
||
await api.delete(`/api/audit/neutralization-mappings/${row.id}`, {
|
||
headers: _mandateHeaders(),
|
||
});
|
||
void _loadNeutMappings();
|
||
} catch (err) {
|
||
console.error('Delete mapping failed:', err);
|
||
}
|
||
}, [selectedMandateId, _loadNeutMappings, confirm, t]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const _handleDeleteMappingsBatch = useCallback(async (rows: any[]) => {
|
||
if (!selectedMandateId || rows.length === 0) return;
|
||
const ok = await confirm(
|
||
t('{n} Zuordnungen wirklich löschen?', { n: String(rows.length) }),
|
||
{ confirmLabel: t('Alle löschen'), variant: 'danger' },
|
||
);
|
||
if (!ok) return;
|
||
try {
|
||
await Promise.all(
|
||
rows.map(row => api.delete(`/api/audit/neutralization-mappings/${row.id}`, {
|
||
headers: _mandateHeaders(),
|
||
}))
|
||
);
|
||
void _loadNeutMappings();
|
||
} catch (err) {
|
||
console.error('Batch delete failed:', err);
|
||
}
|
||
}, [selectedMandateId, _loadNeutMappings, confirm, t]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// ── Auto-load on tab / mandate change ──
|
||
|
||
useEffect(() => {
|
||
if (!selectedMandateId) return;
|
||
if (activeTab === 'ai-log') void _loadAiLog();
|
||
else if (activeTab === 'audit-log') void _loadAuditLog();
|
||
else if (activeTab === 'stats') void _loadStats(statsRange);
|
||
else if (activeTab === 'neutralization') void _loadNeutMappings();
|
||
}, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// ── Content view handler (modal) ──
|
||
|
||
const _handleContentView = useCallback(async (row: any) => {
|
||
if (!selectedMandateId || !row?.id) return;
|
||
setContentModalLoading(true);
|
||
setContentModalTab('input');
|
||
setContentModal({ row, neutralizationMappings: [] });
|
||
try {
|
||
const { data } = await api.get(`/api/audit/ai-log/${row.id}/content`, {
|
||
headers: _mandateHeaders(),
|
||
});
|
||
setContentModal({
|
||
row,
|
||
contentInputFull: data?.contentInputFull,
|
||
contentOutputFull: data?.contentOutputFull,
|
||
contentInputPreview: data?.contentInputPreview,
|
||
contentOutputPreview: data?.contentOutputPreview,
|
||
neutralizationMappings: data?.neutralizationMappings ?? [],
|
||
});
|
||
} catch (err) {
|
||
console.error('Content load failed:', err);
|
||
} finally {
|
||
setContentModalLoading(false);
|
||
}
|
||
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// ── Content download handler ──
|
||
|
||
const _handleContentDownload = useCallback(async (row: any) => {
|
||
if (!selectedMandateId || !row?.id) return;
|
||
try {
|
||
const { data } = await api.get(`/api/audit/ai-log/${row.id}/content`, {
|
||
headers: _mandateHeaders(),
|
||
});
|
||
const ts = row.timestamp ? new Date(row.timestamp * 1000).toISOString() : '–';
|
||
const text = [
|
||
`=== AI-Audit-Eintrag: ${row.id} ===`,
|
||
`Zeitpunkt: ${ts}`,
|
||
`Benutzer: ${row.username || row.userId || '–'}`,
|
||
`Provider: ${row.aiProvider || '–'}`,
|
||
`Modell: ${row.aiModel || '–'}`,
|
||
`Typ: ${row.operationType || '–'}`,
|
||
`Neutralisierung: ${row.neutralizationActive ? 'aktiv' : 'inaktiv'}`,
|
||
`Status: ${row.success ? 'OK' : 'Fehler'}`,
|
||
'',
|
||
'=== Input (was an AI gesendet wurde) ===',
|
||
data?.contentInputFull || data?.contentInputPreview || '(kein Input gespeichert)',
|
||
'',
|
||
'=== Output (AI-Antwort) ===',
|
||
data?.contentOutputFull || data?.contentOutputPreview || '(kein Output gespeichert)',
|
||
].join('\n');
|
||
|
||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `ai-audit-${row.id.slice(0, 8)}.txt`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch (err) {
|
||
console.error('Content download failed:', err);
|
||
}
|
||
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// ── Mapping lookup for modal ──
|
||
|
||
const _modalMappingLookup = useMemo(() => {
|
||
const map = new Map<string, NeutMapping>();
|
||
if (contentModal?.neutralizationMappings) {
|
||
for (const m of contentModal.neutralizationMappings) {
|
||
map.set(m.id, m);
|
||
}
|
||
}
|
||
return map;
|
||
}, [contentModal?.neutralizationMappings]);
|
||
|
||
// ── Column definitions ──
|
||
|
||
const aiLogColumns: ColumnConfig[] = useMemo(() => [
|
||
{ key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 },
|
||
{
|
||
key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140,
|
||
formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'),
|
||
},
|
||
{
|
||
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 },
|
||
{
|
||
key: 'aiProvider', label: t('Provider / Typ'), type: 'text' as any, sortable: true, filterable: true, width: 140,
|
||
formatter: (val: any, row: any) => {
|
||
const provider = val || '–';
|
||
const op = row?.operationType;
|
||
return op ? `${provider} · ${op}` : provider;
|
||
},
|
||
},
|
||
{
|
||
key: 'priceCHF', label: t('Kosten (CHF)'), type: 'number' as any, sortable: true, width: 110,
|
||
formatter: (val: any) => val != null ? Number(val).toFixed(4) : '–',
|
||
},
|
||
{
|
||
key: 'neutralizationActive', label: t('Neutralisierung'), type: 'text' as any, sortable: true, width: 100,
|
||
formatter: (val: any) => val ? '✓' : '–',
|
||
},
|
||
{
|
||
key: 'success', label: t('Status'), type: 'text' as any, sortable: true, filterable: true, width: 80,
|
||
formatter: (val: any) => val ? t('OK') : t('Fehler'),
|
||
cellClassName: (val: any) => val ? styles.statusOk : styles.statusError,
|
||
},
|
||
], [t]);
|
||
|
||
const auditLogColumns: ColumnConfig[] = useMemo(() => [
|
||
{ key: 'timestamp', label: t('Zeitpunkt'), type: 'timestamp' as any, sortable: true, width: 160 },
|
||
{
|
||
key: 'username', label: t('Benutzer'), type: 'text' as any, sortable: true, searchable: true, width: 140,
|
||
formatter: (val: any, row: any) => val || (row?.userId ? row.userId.slice(0, 8) : '–'),
|
||
},
|
||
{
|
||
key: 'category', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 110,
|
||
cellClassName: (val: any) => {
|
||
const color = _CATEGORY_COLORS[val as string];
|
||
return color ? styles[`cat_${val}`] || '' : '';
|
||
},
|
||
formatter: (val: any) => val || '–',
|
||
},
|
||
{ key: 'action', label: t('Aktion'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 140 },
|
||
{ key: 'resourceType', label: t('Ressource'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||
{ key: 'details', label: t('Details'), type: 'text' as any, searchable: true, width: 250 },
|
||
{
|
||
key: 'success', label: t('Status'), type: 'text' as any, sortable: true, width: 70,
|
||
formatter: (val: any) => val ? '✓' : '✗',
|
||
cellClassName: (val: any) => val ? styles.statusOk : styles.statusError,
|
||
},
|
||
{ key: 'ipAddress', label: t('IP'), type: 'text' as any, width: 120 },
|
||
], [t]);
|
||
|
||
const neutColumns: ColumnConfig[] = useMemo(() => [
|
||
{ key: 'placeholder', label: t('Platzhalter'), type: 'text' as any, sortable: true, searchable: true, width: 220 },
|
||
{ 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: '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: '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,
|
||
formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–',
|
||
},
|
||
], [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,
|
||
fetchFilterValues: _makeFetchFilterValues('/api/audit/ai-log'),
|
||
}), [_loadAiLog, aiPagination, _makeFetchFilterValues]);
|
||
|
||
const auditLogHookData = useMemo(() => ({
|
||
refetch: _loadAuditLog,
|
||
pagination: auditPagination,
|
||
fetchFilterValues: _makeFetchFilterValues('/api/audit/log'),
|
||
}), [_loadAuditLog, auditPagination, _makeFetchFilterValues]);
|
||
|
||
const neutHookData = useMemo(() => ({
|
||
refetch: _loadNeutMappings,
|
||
pagination: neutPagination,
|
||
fetchFilterValues: _makeFetchFilterValues('/api/audit/neutralization-mappings'),
|
||
}), [_loadNeutMappings, neutPagination, _makeFetchFilterValues]);
|
||
|
||
// ── Render ──
|
||
|
||
const _tabs: TabId[] = ['audit-log', 'ai-log', 'neutralization', 'stats'];
|
||
|
||
return (
|
||
<div className={styles.wrap}>
|
||
<h2 className={styles.pageTitle}>{t('Compliance & AI-Audit')}</h2>
|
||
<p className={styles.pageDesc}>
|
||
{t('Transparente Übersicht aller AI-Datenflüsse und Sicherheitsereignisse Ihres Mandanten.')}
|
||
</p>
|
||
|
||
{/* Mandate selector */}
|
||
<div className={styles.mandateSelector}>
|
||
<label className={styles.mandateLabel}>{t('Mandant auswählen')}</label>
|
||
<select
|
||
className={styles.mandateSelect}
|
||
value={selectedMandateId || ''}
|
||
onChange={e => setSelectedMandateId(e.target.value || null)}
|
||
disabled={mandatesLoading}
|
||
>
|
||
<option value="">{mandatesLoading ? t('Lade…') : t('— Mandant wählen —')}</option>
|
||
{mandates.map(m => (
|
||
<option key={m.id} value={m.id}>{m.label || m.name || m.id}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{!selectedMandateId ? (
|
||
<p className={styles.emptyText}>{t('Bitte wählen Sie einen Mandanten aus.')}</p>
|
||
) : (
|
||
<>
|
||
{/* Tab bar */}
|
||
<div className={styles.tabBar}>
|
||
{_tabs.map(tab => (
|
||
<button
|
||
key={tab}
|
||
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
||
onClick={() => setActiveTab(tab)}
|
||
>
|
||
{_tabLabel(tab, t)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Tab A: AI Data-Flow Log ── */}
|
||
{activeTab === 'ai-log' && (
|
||
<div className={styles.tabContent}>
|
||
<FormGeneratorTable
|
||
key={`ai-log-${selectedMandateId}`}
|
||
data={aiEntries}
|
||
columns={aiLogColumns}
|
||
loading={aiLoading}
|
||
pagination={true}
|
||
pageSize={_AI_LOG_PAGE_SIZE}
|
||
sortable={true}
|
||
filterable={true}
|
||
searchable={true}
|
||
selectable={false}
|
||
emptyMessage={t('Keine AI-Audit-Einträge vorhanden.')}
|
||
onRefresh={_loadAiLog}
|
||
hookData={aiLogHookData}
|
||
customActions={[
|
||
{
|
||
id: 'viewContent',
|
||
title: t('Input/Output anzeigen'),
|
||
icon: <FaEye />,
|
||
onClick: _handleContentView,
|
||
},
|
||
{
|
||
id: 'downloadContent',
|
||
title: t('Input/Output herunterladen'),
|
||
icon: <FaDownload />,
|
||
onClick: _handleContentDownload,
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Tab B: Audit Log ── */}
|
||
{activeTab === 'audit-log' && (
|
||
<div className={styles.tabContent}>
|
||
<FormGeneratorTable
|
||
key={`audit-log-${selectedMandateId}`}
|
||
data={auditEntries}
|
||
columns={auditLogColumns}
|
||
loading={auditLoading}
|
||
pagination={true}
|
||
pageSize={_AUDIT_LOG_PAGE_SIZE}
|
||
sortable={true}
|
||
filterable={true}
|
||
searchable={true}
|
||
selectable={false}
|
||
emptyMessage={t('Keine Audit-Einträge vorhanden.')}
|
||
onRefresh={_loadAuditLog}
|
||
hookData={auditLogHookData}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Tab C: Statistics ── */}
|
||
{activeTab === 'stats' && (
|
||
<div className={styles.tabContentScrollable}>
|
||
<div className={styles.statsControls}>
|
||
{[7, 30, 90].map(d => (
|
||
<button
|
||
key={d}
|
||
className={`${styles.rangeBtn} ${statsRange === d ? styles.rangeBtnActive : ''}`}
|
||
onClick={() => { setStatsRange(d); void _loadStats(d); }}
|
||
>
|
||
{t('{n} Tage', { n: String(d) })}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{statsLoading ? (
|
||
<p className={styles.loadingText}>{t('Lade Statistiken…')}</p>
|
||
) : !stats ? (
|
||
<p className={styles.emptyText}>{t('Keine Daten verfügbar.')}</p>
|
||
) : (
|
||
<>
|
||
{/* KPIs */}
|
||
<div className={styles.kpiGrid}>
|
||
<div className={styles.kpiCard}>
|
||
<p className={styles.kpiValue}>{stats.totalCalls}</p>
|
||
<p className={styles.kpiLabel}>{t('AI-Aufrufe')}</p>
|
||
</div>
|
||
<div className={styles.kpiCard}>
|
||
<p className={styles.kpiValue}>{stats.neutralizationPercent}%</p>
|
||
<p className={styles.kpiLabel}>{t('Neutralisierungsquote')}</p>
|
||
</div>
|
||
<div className={styles.kpiCard}>
|
||
<p className={styles.kpiValue}>{Object.keys(stats.callsByModel).length}</p>
|
||
<p className={styles.kpiLabel}>{t('Genutzte Modelle')}</p>
|
||
</div>
|
||
<div className={styles.kpiCard}>
|
||
<p className={styles.kpiValue}>
|
||
{stats.costPerDay.reduce((s, d) => s + d.cost, 0).toFixed(2)}
|
||
</p>
|
||
<p className={styles.kpiLabel}>{t('Gesamtkosten (CHF)')}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Charts row 1: Calls/Day + Cost/Day */}
|
||
<div className={styles.chartRow}>
|
||
<div className={styles.chartBlock}>
|
||
<h3 className={styles.chartTitle}>{t('AI-Aufrufe pro Tag')}</h3>
|
||
{stats.callsPerDay.length === 0 ? (
|
||
<p className={styles.meta}>{t('Keine Daten')}</p>
|
||
) : (
|
||
<ResponsiveContainer width="100%" height={220}>
|
||
<LineChart data={stats.callsPerDay}>
|
||
<CartesianGrid strokeDasharray="3 3" />
|
||
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
||
<YAxis allowDecimals={false} tick={{ fontSize: 10 }} />
|
||
<Tooltip />
|
||
<Line type="monotone" dataKey="calls" name={t('Aufrufe')} stroke="#1976d2" dot={false} strokeWidth={2} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
)}
|
||
</div>
|
||
<div className={styles.chartBlock}>
|
||
<h3 className={styles.chartTitle}>{t('Kosten-Verlauf (CHF)')}</h3>
|
||
{stats.costPerDay.length === 0 ? (
|
||
<p className={styles.meta}>{t('Keine Daten')}</p>
|
||
) : (
|
||
<ResponsiveContainer width="100%" height={220}>
|
||
<LineChart data={stats.costPerDay}>
|
||
<CartesianGrid strokeDasharray="3 3" />
|
||
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
||
<YAxis tick={{ fontSize: 10 }} />
|
||
<Tooltip />
|
||
<Line type="monotone" dataKey="cost" name={t('CHF')} stroke="#e65100" dot={false} strokeWidth={2} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Charts row 2: By Model (pie) + By Feature (bar) */}
|
||
<div className={styles.chartRow}>
|
||
<div className={styles.chartBlock}>
|
||
<h3 className={styles.chartTitle}>{t('AI-Aufrufe nach Modell')}</h3>
|
||
{Object.keys(stats.callsByModel).length === 0 ? (
|
||
<p className={styles.meta}>{t('Keine Daten')}</p>
|
||
) : (
|
||
<ResponsiveContainer width="100%" height={240}>
|
||
<PieChart>
|
||
<Pie
|
||
data={Object.entries(stats.callsByModel).map(([name, value]) => ({ name, value }))}
|
||
dataKey="value" nameKey="name"
|
||
cx="50%" cy="50%" outerRadius={80}
|
||
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
|
||
>
|
||
{Object.keys(stats.callsByModel).map((_, i) => (
|
||
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||
))}
|
||
</Pie>
|
||
<Tooltip />
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
)}
|
||
</div>
|
||
<div className={styles.chartBlock}>
|
||
<h3 className={styles.chartTitle}>{t('AI-Aufrufe nach Feature')}</h3>
|
||
{Object.keys(stats.callsByFeature).length === 0 ? (
|
||
<p className={styles.meta}>{t('Keine Daten')}</p>
|
||
) : (
|
||
<ResponsiveContainer width="100%" height={240}>
|
||
<BarChart data={Object.entries(stats.callsByFeature).map(([name, value]) => ({ name, value }))}>
|
||
<CartesianGrid strokeDasharray="3 3" />
|
||
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
|
||
<YAxis allowDecimals={false} tick={{ fontSize: 10 }} />
|
||
<Tooltip />
|
||
<Bar dataKey="value" name={t('Aufrufe')} fill="#00897b" radius={[4, 4, 0, 0]} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Top Users */}
|
||
{Object.keys(stats.topUsers).length > 0 && (
|
||
<div className={styles.chartBlock}>
|
||
<h3 className={styles.chartTitle}>{t('Top-Nutzer nach AI-Aufrufen')}</h3>
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<BarChart
|
||
data={Object.entries(stats.topUsers).map(([name, value]) => ({ name, value }))}
|
||
layout="vertical" margin={{ left: 8, right: 16 }}
|
||
>
|
||
<CartesianGrid strokeDasharray="3 3" />
|
||
<XAxis type="number" allowDecimals={false} />
|
||
<YAxis type="category" dataKey="name" width={140} tick={{ fontSize: 10 }} />
|
||
<Tooltip />
|
||
<Bar dataKey="value" name={t('Aufrufe')} fill="#6a1b9a" radius={[0, 4, 4, 0]} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Tab D: Neutralization Mappings ── */}
|
||
{activeTab === 'neutralization' && (
|
||
<div className={styles.tabContent}>
|
||
<FormGeneratorTable
|
||
key={`neut-${selectedMandateId}`}
|
||
data={neutEntries}
|
||
columns={neutColumns}
|
||
loading={neutLoading}
|
||
pagination={true}
|
||
pageSize={_NEUT_PAGE_SIZE}
|
||
sortable={true}
|
||
filterable={true}
|
||
searchable={true}
|
||
selectable={true}
|
||
emptyMessage={t('Keine Neutralisierungs-Zuordnungen vorhanden.')}
|
||
onRefresh={_loadNeutMappings}
|
||
hookData={neutHookData}
|
||
batchActions={[
|
||
{
|
||
label: t('Ausgewählte löschen'),
|
||
onClick: _handleDeleteMappingsBatch,
|
||
},
|
||
]}
|
||
customActions={[
|
||
{
|
||
id: 'deleteMapping',
|
||
title: t('Zuordnung löschen'),
|
||
icon: <FaTrash />,
|
||
onClick: _handleDeleteMapping,
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* ── Content View Modal ── */}
|
||
{contentModal && (
|
||
<div className={styles.modalOverlay}>
|
||
<div className={styles.modalContainer}>
|
||
<div className={styles.modalHeader}>
|
||
<h3 className={styles.modalTitle}>{t('AI-Audit Inhalt')}</h3>
|
||
<div className={styles.modalMeta}>
|
||
{contentModal.row?.username || contentModal.row?.userId?.slice(0, 8) || '–'}
|
||
{' · '}
|
||
{contentModal.row?.aiModel || '–'}
|
||
{' · '}
|
||
{contentModal.row?.timestamp
|
||
? new Date(contentModal.row.timestamp * 1000).toLocaleString()
|
||
: '–'}
|
||
</div>
|
||
<button
|
||
className={styles.modalClose}
|
||
onClick={() => setContentModal(null)}
|
||
title={t('Schliessen')}
|
||
>
|
||
<FaTimes />
|
||
</button>
|
||
</div>
|
||
|
||
{contentModal.neutralizationMappings.length > 0 && (
|
||
<div className={styles.modalMappingBar}>
|
||
<span className={styles.modalMappingLabel}>
|
||
{t('{n} Platzhalter aufgelöst', { n: String(contentModal.neutralizationMappings.length) })}
|
||
</span>
|
||
<span className={styles.modalMappingHint}>
|
||
{t('Hover über markierte Platzhalter für Originaltext')}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className={styles.modalTabBar}>
|
||
<button
|
||
className={`${styles.modalTab} ${contentModalTab === 'input' ? styles.modalTabActive : ''}`}
|
||
onClick={() => setContentModalTab('input')}
|
||
>
|
||
{t('Input')}
|
||
</button>
|
||
<button
|
||
className={`${styles.modalTab} ${contentModalTab === 'output' ? styles.modalTabActive : ''}`}
|
||
onClick={() => setContentModalTab('output')}
|
||
>
|
||
{t('Output')}
|
||
</button>
|
||
</div>
|
||
|
||
<div className={styles.modalBody}>
|
||
{contentModalLoading ? (
|
||
<p className={styles.loadingText}>{t('Lade Inhalt…')}</p>
|
||
) : (
|
||
<div className={styles.modalTextContent}>
|
||
{contentModalTab === 'input' ? (
|
||
(() => {
|
||
const text = contentModal.contentInputFull
|
||
|| contentModal.contentInputPreview
|
||
|| t('(kein Input gespeichert)');
|
||
return _modalMappingLookup.size > 0
|
||
? _renderHighlightedText(text, _modalMappingLookup)
|
||
: text;
|
||
})()
|
||
) : (
|
||
(() => {
|
||
const text = contentModal.contentOutputFull
|
||
|| contentModal.contentOutputPreview
|
||
|| t('(kein Output gespeichert)');
|
||
return _modalMappingLookup.size > 0
|
||
? _renderHighlightedText(text, _modalMappingLookup)
|
||
: text;
|
||
})()
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<ConfirmDialog />
|
||
</div>
|
||
);
|
||
};
|