/** * 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 } 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 { useApiRequest } from '../../hooks/useApi'; import { useBilling, type BillingBalance } from '../../hooks/useBilling'; import { createCheckoutSession, UserTransaction } from '../../api/billingApi'; import { getUserDataCache } from '../../utils/userCache'; import styles from './Billing.module.css'; const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500]; // ============================================================================ // 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 }>; } // ============================================================================ // BALANCE CARD COMPONENT // ============================================================================ interface BalanceCardProps { balance: BillingBalance; onCheckout?: (mandateId: string, amount: number) => void; checkoutLoading?: boolean; } const BalanceCard: React.FC = ({ balance, onCheckout, checkoutLoading }) => { const [selectedAmount, setSelectedAmount] = useState(STRIPE_AMOUNT_PRESETS[0]); const [showCheckout, setShowCheckout] = useState(false); const _getBillingModelLabel = (model: string) => { if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)'; return 'Prepaid (Mandant)'; }; const canTopUp = balance.billingModel === 'PREPAY_USER' || balance.billingModel === 'PREPAY_MANDATE'; return (

{balance.mandateName}

{_getBillingModelLabel(balance.billingModel)}
{_formatCurrency(balance.balance)}
{balance.isWarning && (
Niedriges Guthaben
)} {canTopUp && onCheckout && (
{!showCheckout ? ( ) : (
)}
)}
); }; // ============================================================================ // 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 { request } = useApiRequest(); const [checkoutLoading, setCheckoutLoading] = useState(false); const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); // 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]); const _handleCheckout = useCallback(async (mandateId: string, amount: number) => { setCheckoutLoading(true); setCheckoutMessage(null); try { const currentUser = getUserDataCache(); const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete('success'); currentUrl.searchParams.delete('canceled'); currentUrl.searchParams.delete('session_id'); currentUrl.hash = ''; const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`; const result = await createCheckoutSession(request, mandateId, { userId: currentUser?.id, amount, returnUrl, }); if (result?.redirectUrl) { window.location.href = result.redirectUrl; } } catch (err: any) { setCheckoutMessage({ type: 'error', text: err.message || 'Fehler beim Erstellen der Checkout-Session' }); setCheckoutLoading(false); } }, [request]); // All user balances (for admin overview cards) const [allUserBalances, setAllUserBalances] = useState([]); const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false); // Statistics state (shared by Overview and Statistics tabs) const [viewStats, setViewStats] = useState(null); const [statsLoading, setStatsLoading] = 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 all user balances for admin overview const _loadAllUserBalances = useCallback(async () => { try { setAllUserBalancesLoading(true); const response = await api.get('/api/billing/view/users/balances'); setAllUserBalances(Array.isArray(response.data) ? response.data : []); } catch { setAllUserBalances([]); } finally { setAllUserBalancesLoading(false); } }, []); // 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]); // Initial data load: load statistics when overview or statistics tab becomes active useEffect(() => { if (activeTab === 'overview' || activeTab === 'statistics') { _loadViewStatistics('month', new Date().getFullYear()); } if (activeTab === 'overview') { _loadAllUserBalances(); } }, [activeTab, _loadViewStatistics, _loadAllUserBalances, 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); const filteredUserBalances = selectedScope === 'personal' ? [] // personal view: only own balance cards, no other users : selectedScope === 'all' ? allUserBalances : allUserBalances.filter(ub => ub.mandateId === selectedScope); return ( <> {/* Balance Cards - own balances */}

Mein Guthaben

{dashboardLoading ? (
Lade Guthaben...
) : filteredBalances.length === 0 ? (
Keine Abrechnungskonten vorhanden
) : (
{filteredBalances.map((balance) => ( ))}
)}
{/* All User Balance Cards (mandate/all scope) */} {filteredUserBalances.length > 0 && (

Benutzer-Guthaben

{allUserBalancesLoading ? (
Lade Benutzer-Guthaben...
) : (
{filteredUserBalances.map((ub, idx) => (

{ub.userName || ub.userId?.slice(0, 8)}

{ub.mandateName}
{_formatCurrency(ub.balance || 0)}
))}
)}
)} {/* Usage Statistics via FormGeneratorReport */}
); })()} {/* ================================================================ */} {/* Tab: Statistik (Dashboard) */} {/* ================================================================ */} {activeTab === 'statistics' && (
)} {/* ================================================================ */} {/* Tab: Transaktionen */} {/* ================================================================ */} {activeTab === 'transactions' && (
{transactionsError && (
{transactionsError}
)}
)}
); }; export default BillingDataView;