frontend_nyla/src/pages/ComplianceAuditPage.tsx
2026-04-17 11:50:25 +02:00

900 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
};