From af6feec4caaf6a5b9c3c12c7b9c1b12b850b4faa Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 14 Apr 2026 11:16:19 +0200 Subject: [PATCH] compliance view --- src/App.tsx | 2 + src/config/pageRegistry.tsx | 3 + src/pages/ComplianceAuditPage.module.css | 188 ++++++++ src/pages/ComplianceAuditPage.tsx | 527 +++++++++++++++++++++++ src/pages/Dashboard.tsx | 8 +- src/pages/IntegrationsOverviewPage.tsx | 2 +- src/pages/Store.tsx | 2 +- src/pages/admin/AdminDemoConfigPage.tsx | 23 +- src/pages/billing/BillingDataView.tsx | 2 +- 9 files changed, 738 insertions(+), 19 deletions(-) create mode 100644 src/pages/ComplianceAuditPage.module.css create mode 100644 src/pages/ComplianceAuditPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 8012870..983bec1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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) */} } /> } /> + } /> } /> } /> diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index f39bacf..e7b7c60 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -46,6 +46,9 @@ export const PAGE_ICONS: Record = { 'page.system.files': , 'page.system.connections': , + // System pages - Overviews + 'page.system.complianceAudit': , + // System pages - Usage 'page.system.billingAdmin': , 'page.system.statistics': , diff --git a/src/pages/ComplianceAuditPage.module.css b/src/pages/ComplianceAuditPage.module.css new file mode 100644 index 0000000..776f300 --- /dev/null +++ b/src/pages/ComplianceAuditPage.module.css @@ -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); +} diff --git a/src/pages/ComplianceAuditPage.tsx b/src/pages/ComplianceAuditPage.tsx new file mode 100644 index 0000000..83940ca --- /dev/null +++ b/src/pages/ComplianceAuditPage.tsx @@ -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 = { + 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; + callsByFeature: Record; + topUsers: Record; + 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([]); + const [mandatesLoading, setMandatesLoading] = useState(true); + const [selectedMandateId, setSelectedMandateId] = useState(null); + const [activeTab, setActiveTab] = useState('ai-log'); + + // ── Tab A: AI-Log state ── + const [aiEntries, setAiEntries] = useState([]); + const [aiPagination, setAiPagination] = useState(undefined); + const [aiLoading, setAiLoading] = useState(false); + + // ── Tab B: Audit-Log state ── + const [auditEntries, setAuditEntries] = useState([]); + const [auditPagination, setAuditPagination] = useState(undefined); + const [auditLoading, setAuditLoading] = useState(false); + + // ── Tab C state ── + const [stats, setStats] = useState(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 { + 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 ( +
+

{t('Compliance & AI-Audit')}

+

+ {t('Transparente Übersicht aller AI-Datenflüsse und Sicherheitsereignisse Ihres Mandanten.')} +

+ + {/* Mandate selector */} +
+ + +
+ + {!selectedMandateId ? ( +

{t('Bitte wählen Sie einen Mandanten aus.')}

+ ) : ( + <> + {/* Tab bar */} +
+ {_tabs.map(tab => ( + + ))} +
+ + {/* ── Tab A: AI Data-Flow Log ── */} + {activeTab === 'ai-log' && ( +
+ , + onClick: _handleContentDownload, + }, + ]} + /> +
+ )} + + {/* ── Tab B: Audit Log ── */} + {activeTab === 'audit-log' && ( +
+ +
+ )} + + {/* ── Tab C: Statistics ── */} + {activeTab === 'stats' && ( +
+
+ {[7, 30, 90].map(d => ( + + ))} +
+ + {statsLoading ? ( +

{t('Lade Statistiken…')}

+ ) : !stats ? ( +

{t('Keine Daten verfügbar.')}

+ ) : ( + <> + {/* KPIs */} +
+
+

{stats.totalCalls}

+

{t('AI-Aufrufe')}

+
+
+

{stats.neutralizationPercent}%

+

{t('Neutralisierungsquote')}

+
+
+

{Object.keys(stats.callsByModel).length}

+

{t('Genutzte Modelle')}

+
+
+

+ {stats.costPerDay.reduce((s, d) => s + d.cost, 0).toFixed(2)} +

+

{t('Gesamtkosten (CHF)')}

+
+
+ + {/* Charts row 1: Calls/Day + Cost/Day */} +
+
+

{t('AI-Aufrufe pro Tag')}

+ {stats.callsPerDay.length === 0 ? ( +

{t('Keine Daten')}

+ ) : ( + + + + + + + + + + )} +
+
+

{t('Kosten-Verlauf (CHF)')}

+ {stats.costPerDay.length === 0 ? ( +

{t('Keine Daten')}

+ ) : ( + + + + + + + + + + )} +
+
+ + {/* Charts row 2: By Model (pie) + By Feature (bar) */} +
+
+

{t('AI-Aufrufe nach Modell')}

+ {Object.keys(stats.callsByModel).length === 0 ? ( +

{t('Keine Daten')}

+ ) : ( + + + ({ 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) => ( + + ))} + + + + + )} +
+
+

{t('AI-Aufrufe nach Feature')}

+ {Object.keys(stats.callsByFeature).length === 0 ? ( +

{t('Keine Daten')}

+ ) : ( + + ({ name, value }))}> + + + + + + + + )} +
+
+ + {/* Top Users */} + {Object.keys(stats.topUsers).length > 0 && ( +
+

{t('Top-Nutzer nach AI-Aufrufen')}

+ + ({ name, value }))} + layout="vertical" margin={{ left: 8, right: 16 }} + > + + + + + + + +
+ )} + + )} +
+ )} + + )} +
+ ); +}; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 4114702..e4c59db 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -85,11 +85,9 @@ export const DashboardPage: React.FC = () => {

{t('Übersicht')}

{totalInstances > 0 && (

- {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), })}

)} diff --git a/src/pages/IntegrationsOverviewPage.tsx b/src/pages/IntegrationsOverviewPage.tsx index 184883b..1c4af13 100644 --- a/src/pages/IntegrationsOverviewPage.tsx +++ b/src/pages/IntegrationsOverviewPage.tsx @@ -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]); diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx index 03f07ff..1a36176 100644 --- a/src/pages/Store.tsx +++ b/src/pages/Store.tsx @@ -153,7 +153,7 @@ const StorePage: React.FC = () => { )} {subscriptionInfo.budgetAiPerUserCHF != null && subscriptionInfo.budgetAiPerUserCHF > 0 && ( - {t('AI-Budget')}: {subscriptionInfo.budgetAiPerUserCHF} CHF / User + {t('AI-Budget')}: {subscriptionInfo.budgetAiPerUserCHF} {t('CHF / Benutzer')} )} {subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && ( diff --git a/src/pages/admin/AdminDemoConfigPage.tsx b/src/pages/admin/AdminDemoConfigPage.tsx index 975cea4..dcbac8d 100644 --- a/src/pages/admin/AdminDemoConfigPage.tsx +++ b/src/pages/admin/AdminDemoConfigPage.tsx @@ -90,12 +90,12 @@ export const AdminDemoConfigPage: React.FC = () => {
-

{t('Demo Configurations')}

-

{t('Load or remove demo environments for presentations and testing.')}

+

{t('Demo-Konfigurationen')}

+

{t('Demo-Umgebungen für Präsentationen und Tests laden oder entfernen.')}

@@ -104,7 +104,7 @@ export const AdminDemoConfigPage: React.FC = () => { {lastResult && (
- {lastResult.action === 'load' ? t('Loaded') : t('Removed')}:{' '} + {lastResult.action === 'load' ? t('Geladen') : t('Entfernt')}:{' '} {lastResult.status === 'ok' ? ( <_SummaryDisplay summary={lastResult.summary} /> ) : ( @@ -114,9 +114,9 @@ export const AdminDemoConfigPage: React.FC = () => { )} {loading && configs.length === 0 ? ( -
{t('Loading...')}
+
{t('Lade…')}
) : configs.length === 0 ? ( -
{t('No demo configurations found.')}
+
{t('Keine Demo-Konfigurationen gefunden.')}
) : (
{configs.map((cfg) => ( @@ -134,7 +134,7 @@ export const AdminDemoConfigPage: React.FC = () => { disabled={actionInProgress !== null} > {actionInProgress === cfg.code ? : } - {t('Load')} + {t('Laden')}
@@ -155,10 +155,11 @@ export const AdminDemoConfigPage: React.FC = () => { ); }; -function _SummaryDisplay({ summary }: { summary?: Record }) { +const _SummaryDisplay: React.FC<{ summary?: Record }> = ({ 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 Done (no changes); + if (sections.length === 0) return {t('Abgeschlossen (keine Änderungen)')}; return ( {sections.map(([key, items]) => ( @@ -168,4 +169,4 @@ function _SummaryDisplay({ summary }: { summary?: Record }) { ))} ); -} +}; diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index bc4f017..f03d9a9 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -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;