compliance view
This commit is contained in:
parent
7758f9a58d
commit
af6feec4ca
9 changed files with 738 additions and 19 deletions
|
|
@ -44,6 +44,7 @@ import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||||
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
|
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
|
||||||
|
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
|
||||||
function App() {
|
function App() {
|
||||||
// Load saved theme preference and set app name on app mount
|
// Load saved theme preference and set app name on app mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -99,6 +100,7 @@ function App() {
|
||||||
{/* System-Seiten (ohne Instanz-Kontext) */}
|
{/* System-Seiten (ohne Instanz-Kontext) */}
|
||||||
<Route path="store" element={<StorePage />} />
|
<Route path="store" element={<StorePage />} />
|
||||||
<Route path="integrations" element={<IntegrationsOverviewPage />} />
|
<Route path="integrations" element={<IntegrationsOverviewPage />} />
|
||||||
|
<Route path="compliance-audit" element={<ComplianceAuditPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="gdpr" element={<GDPRPage />} />
|
<Route path="gdpr" element={<GDPRPage />} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.system.files': <FaRegFileAlt />,
|
'page.system.files': <FaRegFileAlt />,
|
||||||
'page.system.connections': <FaLink />,
|
'page.system.connections': <FaLink />,
|
||||||
|
|
||||||
|
// System pages - Overviews
|
||||||
|
'page.system.complianceAudit': <FaShieldAlt />,
|
||||||
|
|
||||||
// System pages - Usage
|
// System pages - Usage
|
||||||
'page.system.billingAdmin': <FaMoneyBillAlt />,
|
'page.system.billingAdmin': <FaMoneyBillAlt />,
|
||||||
'page.system.statistics': <FaChartBar />,
|
'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>
|
<h1>{t('Übersicht')}</h1>
|
||||||
{totalInstances > 0 && (
|
{totalInstances > 0 && (
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
{t('Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.', {
|
{t('{instanceCount} Feature-Instanzen in {mandateCount} Mandanten', {
|
||||||
instanceCount: totalInstances,
|
instanceCount: String(totalInstances),
|
||||||
instanceWord: totalInstances === 1 ? t('Feature-Instanz') : t('Feature-Instanzen'),
|
mandateCount: String(totalMandates),
|
||||||
mandateCount: totalMandates,
|
|
||||||
mandateWord: totalMandates === 1 ? t('Mandant') : t('Mandanten'),
|
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ export const IntegrationsOverviewPage: React.FC = () => {
|
||||||
return [
|
return [
|
||||||
{ value: s.aiCallCount, label: t('AI-Aufrufe'), sub: `${s.aiCallPeriodDays} ${t('Tage')}` },
|
{ 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.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') },
|
{ value: connectedSystems, label: t('Verbundene Systeme') },
|
||||||
];
|
];
|
||||||
}, [diagram, t]);
|
}, [diagram, t]);
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ const StorePage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
{subscriptionInfo.budgetAiPerUserCHF != null && subscriptionInfo.budgetAiPerUserCHF > 0 && (
|
{subscriptionInfo.budgetAiPerUserCHF != null && subscriptionInfo.budgetAiPerUserCHF > 0 && (
|
||||||
<span className={styles.bannerSeparator}>
|
<span className={styles.bannerSeparator}>
|
||||||
{t('AI-Budget')}: {subscriptionInfo.budgetAiPerUserCHF} CHF / User
|
{t('AI-Budget')}: {subscriptionInfo.budgetAiPerUserCHF} {t('CHF / Benutzer')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
|
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
|
||||||
|
|
|
||||||
|
|
@ -90,12 +90,12 @@ export const AdminDemoConfigPage: React.FC = () => {
|
||||||
<div className={styles.adminPage}>
|
<div className={styles.adminPage}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('Demo Configurations')}</h1>
|
<h1 className={styles.pageTitle}>{t('Demo-Konfigurationen')}</h1>
|
||||||
<p className={styles.pageSubtitle}>{t('Load or remove demo environments for presentations and testing.')}</p>
|
<p className={styles.pageSubtitle}>{t('Demo-Umgebungen für Präsentationen und Tests laden oder entfernen.')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button className={styles.secondaryButton} onClick={_fetchConfigs} disabled={loading}>
|
<button className={styles.secondaryButton} onClick={_fetchConfigs} disabled={loading}>
|
||||||
<FaSync /> {t('Refresh')}
|
<FaSync /> {t('Aktualisieren')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -104,7 +104,7 @@ export const AdminDemoConfigPage: React.FC = () => {
|
||||||
|
|
||||||
{lastResult && (
|
{lastResult && (
|
||||||
<div className={lastResult.status === 'ok' ? demoStyles.successBanner : demoStyles.errorBanner}>
|
<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' ? (
|
{lastResult.status === 'ok' ? (
|
||||||
<_SummaryDisplay summary={lastResult.summary} />
|
<_SummaryDisplay summary={lastResult.summary} />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -114,9 +114,9 @@ export const AdminDemoConfigPage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && configs.length === 0 ? (
|
{loading && configs.length === 0 ? (
|
||||||
<div className={demoStyles.loadingState}>{t('Loading...')}</div>
|
<div className={demoStyles.loadingState}>{t('Lade…')}</div>
|
||||||
) : configs.length === 0 ? (
|
) : 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}>
|
<div className={demoStyles.configGrid}>
|
||||||
{configs.map((cfg) => (
|
{configs.map((cfg) => (
|
||||||
|
|
@ -134,7 +134,7 @@ export const AdminDemoConfigPage: React.FC = () => {
|
||||||
disabled={actionInProgress !== null}
|
disabled={actionInProgress !== null}
|
||||||
>
|
>
|
||||||
{actionInProgress === cfg.code ? <FaSync className={demoStyles.spin} /> : <FaPlay />}
|
{actionInProgress === cfg.code ? <FaSync className={demoStyles.spin} /> : <FaPlay />}
|
||||||
{t('Load')}
|
{t('Laden')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={demoStyles.removeButton}
|
className={demoStyles.removeButton}
|
||||||
|
|
@ -142,7 +142,7 @@ export const AdminDemoConfigPage: React.FC = () => {
|
||||||
disabled={actionInProgress !== null}
|
disabled={actionInProgress !== null}
|
||||||
>
|
>
|
||||||
<FaTrash />
|
<FaTrash />
|
||||||
{t('Remove')}
|
{t('Entfernen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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;
|
if (!summary) return null;
|
||||||
const sections = Object.entries(summary).filter(([, v]) => Array.isArray(v) && (v as unknown[]).length > 0);
|
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 (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{sections.map(([key, items]) => (
|
{sections.map(([key, items]) => (
|
||||||
|
|
@ -168,4 +169,4 @@ function _SummaryDisplay({ summary }: { summary?: Record<string, unknown> }) {
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,7 @@ export const BillingDataView: React.FC = () => {
|
||||||
try {
|
try {
|
||||||
await api.post('/api/billing/checkout/confirm', { sessionId: sessionIdParam });
|
await api.post('/api/billing/checkout/confirm', { sessionId: sessionIdParam });
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wurde verbucht.' });
|
setCheckoutMessage({ type: 'success', text: t('Zahlung erfolgreich. Guthaben wurde verbucht.') });
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const detail = err?.response?.data?.detail;
|
const detail = err?.response?.data?.detail;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue