Merge pull request #31 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
This commit is contained in:
commit
fa75f54ceb
11 changed files with 844 additions and 19 deletions
|
|
@ -44,6 +44,7 @@ import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin
|
|||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
|
||||
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
|
||||
function App() {
|
||||
// Load saved theme preference and set app name on app mount
|
||||
useEffect(() => {
|
||||
|
|
@ -99,6 +100,7 @@ function App() {
|
|||
{/* System-Seiten (ohne Instanz-Kontext) */}
|
||||
<Route path="store" element={<StorePage />} />
|
||||
<Route path="integrations" element={<IntegrationsOverviewPage />} />
|
||||
<Route path="compliance-audit" element={<ComplianceAuditPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="gdpr" element={<GDPRPage />} />
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'page.system.files': <FaRegFileAlt />,
|
||||
'page.system.connections': <FaLink />,
|
||||
|
||||
// System pages - Overviews
|
||||
'page.system.complianceAudit': <FaShieldAlt />,
|
||||
|
||||
// System pages - Usage
|
||||
'page.system.billingAdmin': <FaMoneyBillAlt />,
|
||||
'page.system.statistics': <FaChartBar />,
|
||||
|
|
|
|||
188
src/pages/ComplianceAuditPage.module.css
Normal file
188
src/pages/ComplianceAuditPage.module.css
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 1400px;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.pageDesc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary, #666);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Mandate selector */
|
||||
.mandateSelector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.mandateLabel {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #555);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mandateSelect {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary, #333);
|
||||
background: var(--bg-primary, #fff);
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
.tabBar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #666);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--accent-color, #1976d2);
|
||||
border-bottom-color: var(--accent-color, #1976d2);
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.tabContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loadingText, .emptyText {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
|
||||
/* Status cell styles */
|
||||
.statusOk {
|
||||
color: #2e7d32;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.statusError {
|
||||
color: #c62828;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Stats controls */
|
||||
.statsControls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rangeBtn {
|
||||
padding: 0.35rem 0.9rem;
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary, #fff);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary, #333);
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.rangeBtn:hover {
|
||||
border-color: var(--accent-color, #1976d2);
|
||||
}
|
||||
|
||||
.rangeBtnActive {
|
||||
background: var(--accent-color, #1976d2);
|
||||
color: #fff;
|
||||
border-color: var(--accent-color, #1976d2);
|
||||
}
|
||||
|
||||
/* KPI grid */
|
||||
.kpiGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.kpiCard {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.kpiValue {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
margin: 0 0 0.2rem;
|
||||
}
|
||||
|
||||
.kpiLabel {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary, #666);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Chart blocks */
|
||||
.chartRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chartRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.chartBlock {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
527
src/pages/ComplianceAuditPage.tsx
Normal file
527
src/pages/ComplianceAuditPage.tsx
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
/**
|
||||
* ComplianceAuditPage — Compliance & AI-Audit dashboard.
|
||||
*
|
||||
* Tab A: AI Data-Flow Log — FormGeneratorTable + content download
|
||||
* Tab B: Security / GDPR Audit Log — FormGeneratorTable
|
||||
* Tab C: Aggregated AI-Audit Statistics with charts
|
||||
*/
|
||||
|
||||
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 } from 'react-icons/fa';
|
||||
import api from '../api';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { useUserMandates } from '../hooks/useUserMandates';
|
||||
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';
|
||||
|
||||
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');
|
||||
default: return tabId;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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; }
|
||||
|
||||
const _AI_LOG_PAGE_SIZE = 50;
|
||||
const _AUDIT_LOG_PAGE_SIZE = 100;
|
||||
|
||||
export const ComplianceAuditPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { fetchMandates } = useUserMandates();
|
||||
|
||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<TabId>('ai-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);
|
||||
|
||||
// ── 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 (FormGeneratorTable refetch pattern) ──
|
||||
|
||||
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 { data } = await api.get('/api/audit/ai-log', {
|
||||
params: { limit: pageSize, offset },
|
||||
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 { data } = await api.get('/api/audit/log', {
|
||||
params: { limit: pageSize, offset },
|
||||
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
|
||||
|
||||
// ── 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);
|
||||
}, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Content download handler (Tab A — Akzeptanzkriterium #3) ──
|
||||
|
||||
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 text = [
|
||||
`=== AI-Audit-Eintrag: ${row.id} ===`,
|
||||
'',
|
||||
'--- Input ---',
|
||||
data?.contentInputFull || data?.contentInputPreview || '(kein Input gespeichert)',
|
||||
'',
|
||||
'--- Output ---',
|
||||
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
|
||||
|
||||
// ── 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: 'featureCode', label: t('Feature'), type: 'text' as any, sortable: true, filterable: true, width: 130,
|
||||
formatter: (val: any, row: any) => row?.instanceLabel || val || '–',
|
||||
},
|
||||
{ key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 },
|
||||
{ key: 'operationType', label: t('Typ'), type: 'text' as any, sortable: true, filterable: true, width: 90 },
|
||||
{
|
||||
key: 'tokensInput', label: t('Tokens (Input)'), type: 'number' as any, sortable: true, width: 110,
|
||||
formatter: (val: any) => val != null ? `${val}` : '–',
|
||||
},
|
||||
{
|
||||
key: 'tokensOutput', label: t('Tokens (Output)'), type: 'number' as any, sortable: true, width: 110,
|
||||
formatter: (val: any) => val != null ? `${val}` : '–',
|
||||
},
|
||||
{
|
||||
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]);
|
||||
|
||||
// ── hookData for FormGeneratorTable ──
|
||||
|
||||
const aiLogHookData = useMemo(() => ({
|
||||
refetch: _loadAiLog,
|
||||
pagination: aiPagination,
|
||||
}), [_loadAiLog, aiPagination]);
|
||||
|
||||
const auditLogHookData = useMemo(() => ({
|
||||
refetch: _loadAuditLog,
|
||||
pagination: auditPagination,
|
||||
}), [_loadAuditLog, auditPagination]);
|
||||
|
||||
// ── Render ──
|
||||
|
||||
const _tabs: TabId[] = ['ai-log', 'audit-log', '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} style={{ minHeight: 400 }}>
|
||||
<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: 'downloadContent',
|
||||
title: t('Input/Output herunterladen'),
|
||||
icon: <FaDownload />,
|
||||
onClick: _handleContentDownload,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tab B: Audit Log ── */}
|
||||
{activeTab === 'audit-log' && (
|
||||
<div className={styles.tabContent} style={{ minHeight: 400 }}>
|
||||
<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.tabContent}>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -85,11 +85,9 @@ export const DashboardPage: React.FC = () => {
|
|||
<h1>{t('Übersicht')}</h1>
|
||||
{totalInstances > 0 && (
|
||||
<p className={styles.subtitle}>
|
||||
{t('Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.', {
|
||||
instanceCount: totalInstances,
|
||||
instanceWord: totalInstances === 1 ? t('Feature-Instanz') : t('Feature-Instanzen'),
|
||||
mandateCount: totalMandates,
|
||||
mandateWord: totalMandates === 1 ? t('Mandant') : t('Mandanten'),
|
||||
{t('{instanceCount} Feature-Instanzen in {mandateCount} Mandanten', {
|
||||
instanceCount: String(totalInstances),
|
||||
mandateCount: String(totalMandates),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ export const IntegrationsOverviewPage: React.FC = () => {
|
|||
return [
|
||||
{ value: s.aiCallCount, label: t('AI-Aufrufe'), sub: `${s.aiCallPeriodDays} ${t('Tage')}` },
|
||||
{ value: s.activeWorkflows, label: t('Aktive Workflows'), sub: s.totalWorkflows > 0 ? `${_formatStatNumber(s.totalWorkflows)} ${t('total')}` : undefined },
|
||||
{ value: s.totalRuns, label: t('Workflow-Runs'), sub: s.totalTokens > 0 ? `${_formatStatNumber(s.totalTokens)} Tokens` : undefined },
|
||||
{ value: s.totalRuns, label: t('Workflow-Läufe'), sub: s.totalTokens > 0 ? `${_formatStatNumber(s.totalTokens)} ${t('Tokens')}` : undefined },
|
||||
{ value: connectedSystems, label: t('Verbundene Systeme') },
|
||||
];
|
||||
}, [diagram, t]);
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ const StorePage: React.FC = () => {
|
|||
)}
|
||||
{subscriptionInfo.budgetAiPerUserCHF != null && subscriptionInfo.budgetAiPerUserCHF > 0 && (
|
||||
<span className={styles.bannerSeparator}>
|
||||
{t('AI-Budget')}: {subscriptionInfo.budgetAiPerUserCHF} CHF / User
|
||||
{t('AI-Budget')}: {subscriptionInfo.budgetAiPerUserCHF} {t('CHF / Benutzer')}
|
||||
</span>
|
||||
)}
|
||||
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
|
||||
|
|
|
|||
|
|
@ -90,12 +90,12 @@ export const AdminDemoConfigPage: React.FC = () => {
|
|||
<div className={styles.adminPage}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>{t('Demo Configurations')}</h1>
|
||||
<p className={styles.pageSubtitle}>{t('Load or remove demo environments for presentations and testing.')}</p>
|
||||
<h1 className={styles.pageTitle}>{t('Demo-Konfigurationen')}</h1>
|
||||
<p className={styles.pageSubtitle}>{t('Demo-Umgebungen für Präsentationen und Tests laden oder entfernen.')}</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button className={styles.secondaryButton} onClick={_fetchConfigs} disabled={loading}>
|
||||
<FaSync /> {t('Refresh')}
|
||||
<FaSync /> {t('Aktualisieren')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -104,7 +104,7 @@ export const AdminDemoConfigPage: React.FC = () => {
|
|||
|
||||
{lastResult && (
|
||||
<div className={lastResult.status === 'ok' ? demoStyles.successBanner : demoStyles.errorBanner}>
|
||||
<strong>{lastResult.action === 'load' ? t('Loaded') : t('Removed')}:</strong>{' '}
|
||||
<strong>{lastResult.action === 'load' ? t('Geladen') : t('Entfernt')}:</strong>{' '}
|
||||
{lastResult.status === 'ok' ? (
|
||||
<_SummaryDisplay summary={lastResult.summary} />
|
||||
) : (
|
||||
|
|
@ -114,9 +114,9 @@ export const AdminDemoConfigPage: React.FC = () => {
|
|||
)}
|
||||
|
||||
{loading && configs.length === 0 ? (
|
||||
<div className={demoStyles.loadingState}>{t('Loading...')}</div>
|
||||
<div className={demoStyles.loadingState}>{t('Lade…')}</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className={demoStyles.emptyState}>{t('No demo configurations found.')}</div>
|
||||
<div className={demoStyles.emptyState}>{t('Keine Demo-Konfigurationen gefunden.')}</div>
|
||||
) : (
|
||||
<div className={demoStyles.configGrid}>
|
||||
{configs.map((cfg) => (
|
||||
|
|
@ -134,7 +134,7 @@ export const AdminDemoConfigPage: React.FC = () => {
|
|||
disabled={actionInProgress !== null}
|
||||
>
|
||||
{actionInProgress === cfg.code ? <FaSync className={demoStyles.spin} /> : <FaPlay />}
|
||||
{t('Load')}
|
||||
{t('Laden')}
|
||||
</button>
|
||||
<button
|
||||
className={demoStyles.removeButton}
|
||||
|
|
@ -142,7 +142,7 @@ export const AdminDemoConfigPage: React.FC = () => {
|
|||
disabled={actionInProgress !== null}
|
||||
>
|
||||
<FaTrash />
|
||||
{t('Remove')}
|
||||
{t('Entfernen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -155,10 +155,11 @@ export const AdminDemoConfigPage: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
function _SummaryDisplay({ summary }: { summary?: Record<string, unknown> }) {
|
||||
const _SummaryDisplay: React.FC<{ summary?: Record<string, unknown> }> = ({ summary }) => {
|
||||
const { t } = useLanguage();
|
||||
if (!summary) return null;
|
||||
const sections = Object.entries(summary).filter(([, v]) => Array.isArray(v) && (v as unknown[]).length > 0);
|
||||
if (sections.length === 0) return <span>Done (no changes)</span>;
|
||||
if (sections.length === 0) return <span>{t('Abgeschlossen (keine Änderungen)')}</span>;
|
||||
return (
|
||||
<span>
|
||||
{sections.map(([key, items]) => (
|
||||
|
|
@ -168,4 +169,4 @@ function _SummaryDisplay({ summary }: { summary?: Record<string, unknown> }) {
|
|||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ export const BillingDataView: React.FC = () => {
|
|||
try {
|
||||
await api.post('/api/billing/checkout/confirm', { sessionId: sessionIdParam });
|
||||
if (!cancelled) {
|
||||
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wurde verbucht.' });
|
||||
setCheckoutMessage({ type: 'success', text: t('Zahlung erfolgreich. Guthaben wurde verbucht.') });
|
||||
}
|
||||
} catch (err: any) {
|
||||
const detail = err?.response?.data?.detail;
|
||||
|
|
|
|||
|
|
@ -80,3 +80,28 @@
|
|||
color: #c62828;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.recentTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.recentTable th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #666);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 2px solid var(--border-color, #e0e0e0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recentTable td {
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color, #f0f0f0);
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.recentTable tbody tr:hover {
|
||||
background: var(--bg-secondary, #fafafa);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,39 @@ function _mimeLabel(key: string, t: (k: string) => string): string {
|
|||
|
||||
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828'];
|
||||
|
||||
function _formatTimestamp(ts: number | null | undefined): string {
|
||||
if (ts == null || ts <= 0) return '–';
|
||||
try {
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleString('de-CH', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return '–';
|
||||
}
|
||||
}
|
||||
|
||||
function _shortMime(mime: string): string {
|
||||
const m = (mime || '').toLowerCase();
|
||||
if (m.includes('pdf')) return 'PDF';
|
||||
if (m.includes('wordprocessing') || m.includes('msword')) return 'Word';
|
||||
if (m.includes('spreadsheet') || m.includes('excel')) return 'Excel';
|
||||
if (m.includes('presentation') || m.includes('powerpoint')) return 'PowerPoint';
|
||||
if (m.startsWith('text/')) return 'Text';
|
||||
if (m.startsWith('image/')) return 'Bild';
|
||||
if (m.includes('html')) return 'HTML';
|
||||
return mime || '–';
|
||||
}
|
||||
|
||||
const _STATUS_COLORS: Record<string, string> = {
|
||||
indexed: '#2e7d32',
|
||||
extracted: '#1565c0',
|
||||
embedding: '#6a1b9a',
|
||||
pending: '#e65100',
|
||||
failed: '#c62828',
|
||||
};
|
||||
|
||||
interface RagKpis {
|
||||
indexedDocuments: number;
|
||||
indexedBytesTotal: number;
|
||||
|
|
@ -51,6 +84,14 @@ interface RagKpis {
|
|||
workflowEntities: number;
|
||||
}
|
||||
|
||||
interface RecentlyIndexedDoc {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
status: string;
|
||||
extractedAt: number | null;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
interface RagStatsResponse {
|
||||
error?: string;
|
||||
scope?: {
|
||||
|
|
@ -63,6 +104,7 @@ interface RagStatsResponse {
|
|||
documentsByMimeCategory?: Record<string, number>;
|
||||
chunksByContentType?: Record<string, number>;
|
||||
timelineIndexedDocuments?: Array<{ date: string; indexedDocuments: number }>;
|
||||
recentlyIndexedDocuments?: RecentlyIndexedDoc[];
|
||||
generatedAtUtc?: string;
|
||||
}
|
||||
|
||||
|
|
@ -181,6 +223,45 @@ export const WorkspaceRagInsightsPage: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{(stats?.recentlyIndexedDocuments ?? []).length > 0 && (
|
||||
<div className={styles.chartBlock}>
|
||||
<h3 className={styles.chartTitle}>{t('Zuletzt indexierte Dokumente')}</h3>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className={styles.recentTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('Dateiname')}</th>
|
||||
<th>{t('Format')}</th>
|
||||
<th>{t('Grösse')}</th>
|
||||
<th>{t('Status')}</th>
|
||||
<th>{t('Indexiert am')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(stats?.recentlyIndexedDocuments ?? []).map((doc, i) => (
|
||||
<tr key={i}>
|
||||
<td title={doc.fileName} style={{ maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{doc.fileName || '–'}
|
||||
</td>
|
||||
<td>{_shortMime(doc.mimeType)}</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{formatBinaryDataSizeBytes(doc.totalSize)}</td>
|
||||
<td>
|
||||
<span style={{
|
||||
color: _STATUS_COLORS[doc.status] ?? '#666',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{doc.status}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{_formatTimestamp(doc.extractedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.chartBlock}>
|
||||
<h3 className={styles.chartTitle}>{t('Neu indexierte Dokumente pro Tag')}</h3>
|
||||
{timeline.length === 0 ? (
|
||||
|
|
|
|||
Loading…
Reference in a new issue