frontend_nyla/src/pages/billing/BillingDataView.tsx
2026-03-29 21:55:13 +02:00

839 lines
31 KiB
TypeScript

/**
* BillingDataView
*
* Unified billing page with internal tabs:
* - Tab "Übersicht": Balance cards + Usage summary for the user
* - Tab "Statistik": Dashboard with time-series charts and breakdowns
* - Tab "Transaktionen": Transaction table with FormGeneratorTable
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
import api from '../../api';
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
import { UserTransaction } from '../../api/billingApi';
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
import styles from './Billing.module.css';
// ============================================================================
// HELPER: Currency formatter
// ============================================================================
const _formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
// ============================================================================
// TYPES
// ============================================================================
interface ViewStatistics {
totalCost: number;
transactionCount: number;
costByProvider: Record<string, number>;
costByModel: Record<string, number>;
costByFeature: Record<string, number>;
costByMandate: Record<string, number>;
timeSeries: Array<{ date: string; cost: number; count: number }>;
}
interface DataVolumeInfo {
mandateId: string;
mandateName: string;
usedMB: number;
filesMB: number;
ragIndexMB: number;
maxDataVolumeMB: number | null;
percentUsed: number | null;
warning: boolean;
}
// ============================================================================
// BALANCE CARD COMPONENT
// ============================================================================
interface BalanceCardProps {
balance: BillingBalance;
onOpenMandateAdmin?: (mandateId: string) => void;
}
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onOpenMandateAdmin }) => {
return (
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
<div className={styles.balanceHeader}>
{onOpenMandateAdmin ? (
<button
type="button"
className={styles.mandateName}
onClick={() => onOpenMandateAdmin(balance.mandateId)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
textAlign: 'left',
font: 'inherit',
color: 'inherit',
textDecoration: 'underline',
}}
>
{balance.mandateName}
</button>
) : (
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
)}
</div>
<div className={styles.balanceAmount}>
{_formatCurrency(balance.balance)}
</div>
{balance.isWarning && (
<div className={styles.warningBadge}>
Niedriges Guthaben
</div>
)}
<p
style={{
marginTop: '12px',
fontSize: '13px',
lineHeight: 1.45,
opacity: 0.75,
marginBottom: 0,
}}
>
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration Billing).
</p>
</div>
);
};
// ============================================================================
// TAB NAVIGATION COMPONENT
// ============================================================================
type TabType = 'overview' | 'statistics' | 'transactions';
interface TabNavProps {
activeTab: TabType;
onTabChange: (tab: TabType) => void;
}
const TabNav: React.FC<TabNavProps> = ({ activeTab, onTabChange }) => {
const _navLinkStyle = (isActive: boolean) => ({
padding: '8px 16px',
textDecoration: 'none',
borderRadius: '4px',
backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent',
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
fontWeight: isActive ? 600 : 400,
cursor: 'pointer',
border: 'none',
fontSize: '14px',
});
return (
<nav style={{
display: 'flex',
gap: '8px',
marginBottom: '24px',
borderBottom: '1px solid var(--color-border, #333)',
paddingBottom: '8px'
}}>
<button onClick={() => onTabChange('overview')} style={_navLinkStyle(activeTab === 'overview')}>
Übersicht
</button>
<button onClick={() => onTabChange('statistics')} style={_navLinkStyle(activeTab === 'statistics')}>
Statistik
</button>
<button onClick={() => onTabChange('transactions')} style={_navLinkStyle(activeTab === 'transactions')}>
Transaktionen
</button>
</nav>
);
};
// ============================================================================
// HELPERS: Convert viewStats to ReportSection arrays
// ============================================================================
function _recordToChartData(record: Record<string, number>): ReportChartDataPoint[] {
return Object.entries(record)
.sort((a, b) => b[1] - a[1])
.map(([key, value]) => ({ key: key || '—', value }));
}
function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] {
const topProvider = Object.entries(viewStats.costByProvider).sort((a, b) => b[1] - a[1])[0];
const topModel = Object.entries(viewStats.costByModel || {}).sort((a, b) => b[1] - a[1])[0];
const topFeature = Object.entries(viewStats.costByFeature).sort((a, b) => b[1] - a[1])[0];
return [
{
type: 'kpiGrid',
items: [
{
label: 'Gesamtkosten',
value: _formatCurrency(viewStats.totalCost),
subtitle: `${viewStats.transactionCount} Transaktionen`
},
{
label: 'Anbieter',
value: Object.keys(viewStats.costByProvider).length,
subtitle: topProvider ? `Top: ${topProvider[0]}` : 'Keine Nutzung'
},
{
label: 'Modelle',
value: Object.keys(viewStats.costByModel || {}).length,
subtitle: topModel ? `Top: ${topModel[0]}` : 'Keine Nutzung'
},
{
label: 'Features',
value: Object.keys(viewStats.costByFeature).length,
subtitle: topFeature ? `Top: ${topFeature[0]}` : 'Keine Nutzung'
}
]
},
{
type: 'horizontalBar',
title: 'Kosten nach Anbieter',
data: _recordToChartData(viewStats.costByProvider),
formatValue: _formatCurrency,
span: 'half' as const
},
{
type: 'horizontalBar',
title: 'Kosten nach Modell',
data: _recordToChartData(viewStats.costByModel || {}),
formatValue: _formatCurrency,
span: 'half' as const
},
{
type: 'horizontalBar',
title: 'Kosten nach Feature',
data: _recordToChartData(viewStats.costByFeature),
formatValue: _formatCurrency,
span: 'half' as const
}
];
}
function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
// Convert timeSeries to barChart data
const timeSeriesData: ReportChartDataPoint[] = viewStats.timeSeries.map(ts => ({
key: ts.date,
value: ts.cost
}));
const avgCost = viewStats.transactionCount > 0
? viewStats.totalCost / viewStats.transactionCount
: 0;
return [
{
type: 'barChart',
title: 'Kostenentwicklung',
data: timeSeriesData,
formatValue: _formatCurrency,
span: 'full' as const
},
{
type: 'pieChart',
title: 'Verteilung nach Anbieter',
data: _recordToChartData(viewStats.costByProvider),
formatValue: _formatCurrency,
donut: true,
span: 'half' as const
},
{
type: 'pieChart',
title: 'Verteilung nach Modell',
data: _recordToChartData(viewStats.costByModel || {}),
formatValue: _formatCurrency,
donut: true,
span: 'half' as const
},
{
type: 'pieChart',
title: 'Verteilung nach Feature',
data: _recordToChartData(viewStats.costByFeature),
formatValue: _formatCurrency,
donut: true,
span: 'half' as const
},
{
type: 'horizontalBar',
title: 'Kosten nach Mandant',
data: _recordToChartData(viewStats.costByMandate),
formatValue: _formatCurrency,
span: 'half' as const
},
{
type: 'table',
title: 'Zusammenfassung',
span: 'half' as const,
columns: [
{ key: 'metric', label: 'Kennzahl' },
{ key: 'value', label: 'Wert', align: 'right' as const }
],
rows: [
{ metric: 'Gesamtkosten', value: _formatCurrency(viewStats.totalCost) },
{ metric: 'Transaktionen', value: String(viewStats.transactionCount) },
{ metric: 'Durchschnitt / Transaktion', value: _formatCurrency(avgCost) },
{ metric: 'Anbieter', value: String(Object.keys(viewStats.costByProvider).length) },
{ metric: 'Modelle', value: String(Object.keys(viewStats.costByModel || {}).length) },
{ metric: 'Features', value: String(Object.keys(viewStats.costByFeature).length) },
{ metric: 'Mandanten', value: String(Object.keys(viewStats.costByMandate).length) }
]
}
];
}
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('overview');
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const _openMandateBillingAdmin = useCallback((mandateId: string) => {
navigate(`/admin/billing?mandate=${encodeURIComponent(mandateId)}`);
}, [navigate]);
// Scope filter: 'personal' | 'all' | mandateId
const [selectedScope, setSelectedScope] = useState<string>('personal');
// Dashboard state (for Overview tab)
const {
balances,
loading: dashboardLoading,
refetch: refetchBalances,
} = useBilling();
const successParam = searchParams.get('success');
const canceledParam = searchParams.get('canceled');
const sessionIdParam = searchParams.get('session_id');
useEffect(() => {
let cancelled = false;
const _confirmCheckoutIfNeeded = async () => {
if (successParam !== 'true') {
if (canceledParam === 'true' && !cancelled) {
setCheckoutMessage({ type: 'error', text: 'Zahlung abgebrochen.' });
}
return;
}
if (!sessionIdParam) {
if (!cancelled) {
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.' });
}
refetchBalances();
return;
}
try {
await api.post('/api/billing/checkout/confirm', { sessionId: sessionIdParam });
if (!cancelled) {
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wurde verbucht.' });
}
} catch (err: any) {
const detail = err?.response?.data?.detail;
if (!cancelled) {
setCheckoutMessage({
type: 'error',
text: detail || 'Zahlung erfolgreich, aber Verbuchung konnte nicht bestaetigt werden.'
});
}
} finally {
refetchBalances();
}
};
_confirmCheckoutIfNeeded();
return () => {
cancelled = true;
};
}, [successParam, canceledParam, sessionIdParam, refetchBalances]);
const _clearStripeParams = useCallback(() => {
searchParams.delete('success');
searchParams.delete('canceled');
searchParams.delete('session_id');
setSearchParams(searchParams, { replace: true });
setCheckoutMessage(null);
}, [searchParams, setSearchParams]);
// Statistics state (shared by Overview and Statistics tabs)
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
// Storage volume state (for Statistics tab)
const [storageData, setStorageData] = useState<DataVolumeInfo[]>([]);
const [storageLoading, setStorageLoading] = useState(false);
// Transactions state (for Transactions tab)
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
const [transactionsLoading, setTransactionsLoading] = useState(false);
const [transactionsError, setTransactionsError] = useState<string | null>(null);
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
// Load aggregated statistics from the view/statistics route
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
try {
setStatsLoading(true);
const params: any = { period, year };
if (period === 'day' && month) {
params.month = month;
}
// Apply scope filter
if (selectedScope === 'personal') {
params.scope = 'personal';
} else if (selectedScope !== 'all') {
params.scope = 'mandate';
params.mandateId = selectedScope;
} else {
params.scope = 'all';
}
const response = await api.get('/api/billing/view/statistics', { params });
setViewStats(response.data);
} catch (err: any) {
console.error('Failed to load statistics:', err);
setViewStats(null);
} finally {
setStatsLoading(false);
}
}, [selectedScope]);
// Handle filter changes from FormGeneratorReport (user changes period/year/month)
const _handleStatsFilterChange = useCallback((filterState: ReportFilterState) => {
const period = filterState.period || 'month';
const year = filterState.year || new Date().getFullYear();
const month = filterState.month;
_loadViewStatistics(period, year, month);
}, [_loadViewStatistics]);
// Load storage volume for all accessible mandates
const _loadStorageData = useCallback(async () => {
const mandateIds = new Set<string>();
for (const b of balances) {
if (selectedScope === 'personal' || selectedScope === 'all' || selectedScope === b.mandateId) {
mandateIds.add(b.mandateId);
}
}
if (mandateIds.size === 0) {
setStorageData([]);
return;
}
setStorageLoading(true);
try {
const mandateNameMap = new Map(balances.map(b => [b.mandateId, b.mandateName]));
const results = await Promise.all(
Array.from(mandateIds).map(async (mid) => {
try {
const resp = await api.get(`/api/subscription/data-volume/${mid}`);
return { ...resp.data, mandateName: mandateNameMap.get(mid) || mid.slice(0, 8) } as DataVolumeInfo;
} catch {
return null;
}
})
);
setStorageData(results.filter((r): r is DataVolumeInfo => r !== null));
} catch {
setStorageData([]);
} finally {
setStorageLoading(false);
}
}, [balances, selectedScope]);
// Initial data load
useEffect(() => {
if (activeTab === 'overview' || activeTab === 'statistics') {
_loadViewStatistics('month', new Date().getFullYear());
_loadStorageData();
}
}, [activeTab, _loadViewStatistics, _loadStorageData, selectedScope]);
// Load transactions with pagination support
const _loadTransactions = useCallback(async (paginationParams?: any) => {
try {
setTransactionsLoading(true);
setTransactionsError(null);
const params: any = {};
if (paginationParams && typeof paginationParams === 'object' && 'page' in paginationParams) {
const pObj: any = {};
if (paginationParams.page !== undefined) pObj.page = paginationParams.page;
if (paginationParams.pageSize !== undefined) pObj.pageSize = paginationParams.pageSize;
if (paginationParams.sort) pObj.sort = paginationParams.sort;
if (paginationParams.filters) pObj.filters = paginationParams.filters;
if (paginationParams.search) pObj.search = paginationParams.search;
if (Object.keys(pObj).length > 0) {
params.pagination = JSON.stringify(pObj);
}
}
const response = await api.get('/api/billing/view/users/transactions', { params });
const data = response.data;
// Handle PaginatedResponse format: { items: [...], pagination: {...} }
if (data && typeof data === 'object' && 'items' in data) {
setTransactions(Array.isArray(data.items) ? data.items : []);
if (data.pagination) {
setTransactionsPagination(data.pagination);
}
} else {
// Backward compatibility: plain array response
setTransactions(Array.isArray(data) ? data : []);
}
} catch (err: any) {
console.error('Failed to load transactions:', err);
setTransactionsError(err.response?.data?.detail || err.message || 'Fehler beim Laden der Transaktionen');
} finally {
setTransactionsLoading(false);
}
}, []);
// Load transactions when switching to transactions tab
useEffect(() => {
if (activeTab === 'transactions') {
_loadTransactions();
}
}, [activeTab, _loadTransactions]);
// hookData for FormGeneratorTable
const transactionsHookData = useMemo(() => ({
refetch: _loadTransactions,
pagination: transactionsPagination || undefined,
}), [_loadTransactions, transactionsPagination]);
// Table column definitions
const columns: ColumnConfig[] = useMemo(() => [
{ key: 'createdAt', label: 'Datum', type: 'timestamp' as any, sortable: true, width: 160 },
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
{ key: 'userName', label: 'Benutzer', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
{ key: 'transactionType', label: 'Typ', type: 'text' as any, sortable: true, filterable: true, width: 100 },
{ key: 'description', label: 'Beschreibung', type: 'text' as any, searchable: true, width: 250 },
{ key: 'aicoreProvider', label: 'Anbieter', type: 'text' as any, sortable: true, filterable: true, width: 120 },
{ key: 'aicoreModel', label: 'Modell', type: 'text' as any, sortable: true, filterable: true, width: 150 },
{ key: 'featureCode', label: 'Feature', type: 'text' as any, sortable: true, filterable: true, width: 120 },
{ key: 'amount', label: 'Betrag (CHF)', type: 'number' as any, sortable: true, width: 120 },
], []);
// Build report sections based on current data
const overviewSections = useMemo<ReportSection[]>(() => {
if (!viewStats) return [];
return _buildOverviewSections(viewStats);
}, [viewStats]);
const statisticsSections = useMemo<ReportSection[]>(() => {
if (!viewStats) return [];
return _buildStatisticsSections(viewStats);
}, [viewStats]);
// Period selector config (shared between overview and statistics)
const periodSelectorConfig = useMemo(() => ({
periods: ['month' as const, 'day' as const],
defaultPeriod: 'month' as const,
showYear: true,
showMonth: true,
defaultYear: new Date().getFullYear(),
defaultMonth: new Date().getMonth() + 1
}), []);
// Build scope options from balances (mandates the user has access to)
const scopeOptions = useMemo(() => {
const options: Array<{ value: string; label: string }> = [
{ value: 'personal', label: 'Meine Kosten' },
];
// Add mandate options from balances
const seen = new Set<string>();
for (const b of balances) {
if (!seen.has(b.mandateId)) {
seen.add(b.mandateId);
options.push({ value: b.mandateId, label: `Mandant: ${b.mandateName}` });
}
}
options.push({ value: 'all', label: 'Alle (RBAC)' });
return options;
}, [balances]);
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1>Billing</h1>
<p className={styles.subtitle}>Guthaben, Statistiken und Transaktionen</p>
</div>
{activeTab !== 'transactions' && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<label style={{ fontSize: '13px', opacity: 0.7 }}>Ansicht:</label>
<select
className={styles.select || ''}
value={selectedScope}
onChange={(e) => setSelectedScope(e.target.value)}
>
{scopeOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
)}
</div>
</header>
<TabNav activeTab={activeTab} onTabChange={setActiveTab} />
{checkoutMessage && (
<div className={checkoutMessage.type === 'success' ? styles.successMessage : styles.errorMessage} style={{ marginBottom: '1rem' }}>
{checkoutMessage.text}
{(successParam || canceledParam) && (
<button type="button" onClick={_clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', color: 'inherit' }}>Schliessen</button>
)}
</div>
)}
{/* ================================================================ */}
{/* Tab: Übersicht (My Overview) */}
{/* ================================================================ */}
{activeTab === 'overview' && (() => {
// Filter balances and user accounts by scope
const filteredBalances = selectedScope === 'personal' || selectedScope === 'all'
? balances
: balances.filter(b => b.mandateId === selectedScope);
return (
<>
{/* Balance Cards - own balances */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Mein Guthaben</h2>
{dashboardLoading ? (
<div className={styles.loadingPlaceholder}>Lade Guthaben...</div>
) : filteredBalances.length === 0 ? (
<div className={styles.noData}>Keine Abrechnungskonten vorhanden</div>
) : (
<div className={styles.balanceGrid}>
{filteredBalances.map((balance) => (
<BalanceCard
key={balance.mandateId}
balance={balance}
onOpenMandateAdmin={_openMandateBillingAdmin}
/>
))}
</div>
)}
</section>
{/* Storage quick info */}
{!storageLoading && storageData.length > 0 && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Speicher</h2>
<div className={styles.balanceGrid}>
{storageData.map((sv) => {
const pct = sv.percentUsed ?? 0;
const barColor = pct >= 90
? 'var(--color-error, #ef4444)'
: pct >= 70
? 'var(--color-warning, #f59e0b)'
: 'var(--primary-color, #3b82f6)';
return (
<div key={sv.mandateId} className={styles.balanceCard}>
<h3 className={styles.mandateName}>{sv.mandateName}</h3>
<div className={styles.balanceAmount} style={{ fontSize: '1.3rem' }}>
{formatBinaryDataSizeFromMebibytes(sv.usedMB)}
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', marginLeft: '6px' }}>
/ {sv.maxDataVolumeMB != null ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) : '∞'}
</span>
</div>
{sv.maxDataVolumeMB != null && (
<div style={{
height: '6px',
background: 'var(--bg-secondary, #2a2a2a)',
borderRadius: '3px',
overflow: 'hidden',
marginTop: '10px',
}}>
<div style={{
height: '100%',
width: `${Math.min(pct, 100)}%`,
background: barColor,
borderRadius: '3px',
minWidth: pct > 0 ? '3px' : '0',
}} />
</div>
)}
{sv.warning && (
<div className={styles.warningBadge} style={{ marginTop: '8px' }}>
Speicher knapp
</div>
)}
</div>
);
})}
</div>
</section>
)}
{/* Usage Statistics via FormGeneratorReport */}
<section className={styles.section}>
<FormGeneratorReport
title={selectedScope === 'personal' ? 'Meine Nutzung' : 'Nutzungsübersicht'}
loading={statsLoading}
sections={overviewSections}
noDataMessage="Keine Statistiken verfügbar"
currencyCode="CHF"
/>
</section>
</>
);
})()}
{/* ================================================================ */}
{/* Tab: Statistik (Dashboard) */}
{/* ================================================================ */}
{activeTab === 'statistics' && (
<>
{/* Storage volume section */}
<section className={styles.section}>
<div className={styles.statisticsChart}>
<h3 style={{ margin: '0 0 16px 0', fontSize: '1.1rem', fontWeight: 600 }}>
Speicherverbrauch
</h3>
{storageLoading ? (
<div className={styles.loadingPlaceholder}>Lade Speicherdaten...</div>
) : storageData.length === 0 ? (
<div className={styles.noData}>Keine Speicherdaten verfügbar</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{storageData.map((sv) => {
const usedLabel = formatBinaryDataSizeFromMebibytes(sv.usedMB);
const maxLabel = sv.maxDataVolumeMB != null
? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB)
: 'unbegrenzt';
const pct = sv.percentUsed ?? 0;
const barColor = pct >= 90
? 'var(--color-error, #ef4444)'
: pct >= 70
? 'var(--color-warning, #f59e0b)'
: 'var(--primary-color, #3b82f6)';
return (
<div key={sv.mandateId} style={{
background: 'var(--bg-secondary, #2a2a2a)',
borderRadius: '8px',
padding: '14px 16px',
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px',
}}>
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
{sv.mandateName}
</span>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', fontFamily: 'monospace' }}>
{usedLabel} / {maxLabel}
{sv.percentUsed != null && (
<span style={{ marginLeft: '8px', color: barColor, fontWeight: 600 }}>
({pct.toFixed(1)}%)
</span>
)}
</span>
</div>
{sv.maxDataVolumeMB != null && (
<div style={{
height: '10px',
background: 'var(--surface-color, #1e1e1e)',
borderRadius: '5px',
overflow: 'hidden',
marginBottom: '8px',
}}>
<div style={{
height: '100%',
width: `${Math.min(pct, 100)}%`,
background: barColor,
borderRadius: '5px',
transition: 'width 0.4s ease',
minWidth: pct > 0 ? '4px' : '0',
}} />
</div>
)}
<div style={{
display: 'flex',
gap: '16px',
fontSize: '0.8rem',
color: 'var(--text-secondary, #888)',
}}>
<span>Dateien: {formatBinaryDataSizeFromMebibytes(sv.filesMB)}</span>
<span>RAG-Index: {formatBinaryDataSizeFromMebibytes(sv.ragIndexMB)}</span>
</div>
</div>
);
})}
</div>
)}
</div>
</section>
{/* AI usage statistics */}
<section className={styles.section}>
<FormGeneratorReport
title="Nutzungsstatistik"
subtitle="Detaillierte Analyse der AI-Nutzung"
periodSelector={periodSelectorConfig}
onFilterChange={_handleStatsFilterChange}
loading={statsLoading}
sections={statisticsSections}
noDataMessage="Keine Statistiken verfügbar"
currencyCode="CHF"
/>
</section>
</>
)}
{/* ================================================================ */}
{/* Tab: Transaktionen */}
{/* ================================================================ */}
{activeTab === 'transactions' && (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: '500px' }}>
{transactionsError && (
<div className={styles.errorMessage}>
{transactionsError}
</div>
)}
<FormGeneratorTable
data={transactions}
columns={columns}
apiEndpoint="/api/billing/view/users/transactions"
loading={transactionsLoading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
emptyMessage="Keine Transaktionen vorhanden"
onRefresh={_loadTransactions}
hookData={transactionsHookData}
/>
</div>
)}
</div>
);
};
export default BillingDataView;