/** * 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; costByModel: Record; costByFeature: Record; costByMandate: Record; 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 = ({ balance, onOpenMandateAdmin }) => { return (
{onOpenMandateAdmin ? ( ) : (

{balance.mandateName}

)}
{_formatCurrency(balance.balance)}
{balance.isWarning && (
Niedriges Guthaben
)}

Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing).

); }; // ============================================================================ // TAB NAVIGATION COMPONENT // ============================================================================ type TabType = 'overview' | 'statistics' | 'transactions'; interface TabNavProps { activeTab: TabType; onTabChange: (tab: TabType) => void; } const TabNav: React.FC = ({ 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 ( ); }; // ============================================================================ // HELPERS: Convert viewStats to ReportSection arrays // ============================================================================ function _recordToChartData(record: Record): 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('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('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(null); const [statsLoading, setStatsLoading] = useState(false); // Storage volume state (for Statistics tab) const [storageData, setStorageData] = useState([]); const [storageLoading, setStorageLoading] = useState(false); // Transactions state (for Transactions tab) const [transactions, setTransactions] = useState([]); const [transactionsLoading, setTransactionsLoading] = useState(false); const [transactionsError, setTransactionsError] = useState(null); const [transactionsPagination, setTransactionsPagination] = useState(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(); 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(() => { if (!viewStats) return []; return _buildOverviewSections(viewStats); }, [viewStats]); const statisticsSections = useMemo(() => { 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(); 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 (

Billing

Guthaben, Statistiken und Transaktionen

{activeTab !== 'transactions' && (
)}
{checkoutMessage && (
{checkoutMessage.text} {(successParam || canceledParam) && ( )}
)} {/* ================================================================ */} {/* 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 */}

Mein Guthaben

{dashboardLoading ? (
Lade Guthaben...
) : filteredBalances.length === 0 ? (
Keine Abrechnungskonten vorhanden
) : (
{filteredBalances.map((balance) => ( ))}
)}
{/* Storage quick info */} {!storageLoading && storageData.length > 0 && (

Speicher

{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 (

{sv.mandateName}

{formatBinaryDataSizeFromMebibytes(sv.usedMB)} / {sv.maxDataVolumeMB != null ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) : '∞'}
{sv.maxDataVolumeMB != null && (
0 ? '3px' : '0', }} />
)} {sv.warning && (
Speicher knapp
)}
); })}
)} {/* Usage Statistics via FormGeneratorReport */}
); })()} {/* ================================================================ */} {/* Tab: Statistik (Dashboard) */} {/* ================================================================ */} {activeTab === 'statistics' && ( <> {/* Storage volume section */}

Speicherverbrauch

{storageLoading ? (
Lade Speicherdaten...
) : storageData.length === 0 ? (
Keine Speicherdaten verfügbar
) : (
{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 (
{sv.mandateName} {usedLabel} / {maxLabel} {sv.percentUsed != null && ( ({pct.toFixed(1)}%) )}
{sv.maxDataVolumeMB != null && (
0 ? '4px' : '0', }} />
)}
Dateien: {formatBinaryDataSizeFromMebibytes(sv.filesMB)} RAG-Index: {formatBinaryDataSizeFromMebibytes(sv.ragIndexMB)}
); })}
)}
{/* AI usage statistics */}
)} {/* ================================================================ */} {/* Tab: Transaktionen */} {/* ================================================================ */} {activeTab === 'transactions' && (
{transactionsError && (
{transactionsError}
)}
)}
); }; export default BillingDataView;