compliance view

This commit is contained in:
ValueOn AG 2026-04-14 11:16:19 +02:00
parent 7758f9a58d
commit af6feec4ca
9 changed files with 738 additions and 19 deletions

View file

@ -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 />} />

View file

@ -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 />,

View 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);
}

View 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>
);
};

View file

@ -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>
)}

View file

@ -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]);

View file

@ -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 && (

View file

@ -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>
);
}
};

View file

@ -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;