/** * BillingDataView (Statistiken) * * Unified statistics page with internal tabs: * - Tab "Übersicht": KPI grid with cost, balance, storage metrics * - Tab "Diagramme": Charts with pie/bar toggle, cost trends * - Tab "Transaktionen": Transaction table with scope filter */ 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 { useBilling } from '../../hooks/useBilling'; import { UserTransaction } from '../../api/billingApi'; import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize'; import { useLanguage } from '../../providers/language/LanguageContext'; import styles from './Billing.module.css'; type TranslateFn = (key: string, params?: Record) => string; // ============================================================================ // 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; } // ============================================================================ // TAB NAVIGATION COMPONENT // ============================================================================ type TabType = 'overview' | 'diagrams' | 'transactions'; interface TabNavProps { activeTab: TabType; onTabChange: (tab: TabType) => void; } const TabNav: React.FC = ({ activeTab, onTabChange }) => { const { t } = useLanguage(); const _navLinkStyle = (isActive: boolean) => ({ padding: '8px 16px', textDecoration: 'none', borderRadius: '4px', backgroundColor: isActive ? 'var(--primary-color, #F25843)' : '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, tr: TranslateFn): ReportChartDataPoint[] { return Object.entries(record) .sort((a, b) => b[1] - a[1]) .map(([key, value]) => ({ key: key || tr('—'), value })); } function _buildOverviewSections( viewStats: ViewStatistics, totalBalance: number, totalStorageMB: number, tr: TranslateFn, ): 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: tr('Gesamtkosten'), value: _formatCurrency(viewStats.totalCost), subtitle: tr('{n} Transaktionen', { n: String(viewStats.transactionCount) }), }, { label: tr('Anbieter'), value: Object.keys(viewStats.costByProvider).length, subtitle: topProvider ? tr('Top: {name}', { name: topProvider[0] }) : tr('Keine Nutzung'), }, { label: tr('Modelle'), value: Object.keys(viewStats.costByModel || {}).length, subtitle: topModel ? tr('Top: {name}', { name: topModel[0] }) : tr('Keine Nutzung'), }, { label: tr('Features'), value: Object.keys(viewStats.costByFeature).length, subtitle: topFeature ? tr('Top: {name}', { name: topFeature[0] }) : tr('Keine Nutzung'), }, { label: tr('Guthaben'), value: _formatCurrency(totalBalance), }, { label: tr('Speicher'), value: formatBinaryDataSizeFromMebibytes(totalStorageMB), }, ] }, ]; } function _buildDiagramSections(viewStats: ViewStatistics, chartMode: 'pie' | 'bar', tr: TranslateFn): ReportSection[] { const timeSeriesData: ReportChartDataPoint[] = viewStats.timeSeries.map(ts => ({ key: ts.date, value: ts.cost })); const avgCost = viewStats.transactionCount > 0 ? viewStats.totalCost / viewStats.transactionCount : 0; const chartType = chartMode === 'pie' ? 'pieChart' : 'horizontalBar'; return [ { type: 'kpiGrid', items: [ { label: tr('Gesamtkosten'), value: _formatCurrency(viewStats.totalCost), subtitle: tr('{n} Transaktionen', { n: String(viewStats.transactionCount) }) }, { label: tr('Durchschnitt'), value: _formatCurrency(avgCost), subtitle: tr('pro Transaktion') }, { label: tr('Anbieter'), value: Object.keys(viewStats.costByProvider).length }, { label: tr('Modelle'), value: Object.keys(viewStats.costByModel || {}).length }, { label: tr('Features'), value: Object.keys(viewStats.costByFeature).length }, { label: tr('Mandanten'), value: Object.keys(viewStats.costByMandate).length }, ] }, { type: 'barChart', title: tr('Kostenentwicklung'), data: timeSeriesData, formatValue: _formatCurrency, span: 'full' as const }, { type: chartType, title: tr('Kosten nach Anbieter'), data: _recordToChartData(viewStats.costByProvider, tr), formatValue: _formatCurrency, donut: chartMode === 'pie', span: 'half' as const }, { type: chartType, title: tr('Kosten nach Modell'), data: _recordToChartData(viewStats.costByModel || {}, tr), formatValue: _formatCurrency, donut: chartMode === 'pie', span: 'half' as const }, { type: chartType, title: tr('Kosten nach Feature'), data: _recordToChartData(viewStats.costByFeature, tr), formatValue: _formatCurrency, donut: chartMode === 'pie', span: 'half' as const }, { type: chartType, title: tr('Kosten nach Mandant'), data: _recordToChartData(viewStats.costByMandate, tr), formatValue: _formatCurrency, donut: chartMode === 'pie', span: 'half' as const }, ]; } // ============================================================================ // MAIN COMPONENT // ============================================================================ export const BillingDataView: React.FC = () => { const { t } = useLanguage(); const [activeTab, setActiveTab] = useState('overview'); const [searchParams, setSearchParams] = useSearchParams(); const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [chartMode, setChartMode] = useState<'pie' | 'bar'>('pie'); const [onlyMyData, setOnlyMyData] = useState(false); // 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: t('Zahlung abgebrochen.') }); } return; } if (!sessionIdParam) { if (!cancelled) { setCheckoutMessage({ type: 'success', text: t('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 || t('Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt 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 [, 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); // Unified scope params -- single source of truth for all tab API calls const _scopeParams = useMemo((): Record => { if (onlyMyData) return { scope: 'personal' }; if (selectedScope === 'personal') return { scope: 'personal' }; if (selectedScope === 'all') return { scope: 'all' }; return { scope: 'mandate', mandateId: selectedScope }; }, [selectedScope, onlyMyData]); // Load aggregated statistics from the view/statistics route const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => { try { setStatsLoading(true); const params: Record = { period, year, ..._scopeParams }; if (period === 'day' && month) { params.month = month; } 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); } }, [_scopeParams]); // 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 === 'diagrams') { _loadViewStatistics('month', new Date().getFullYear()); _loadStorageData(); } }, [activeTab, _loadViewStatistics, _loadStorageData]); // Load transactions with pagination support + scope filter const _loadTransactions = useCallback(async (paginationParams?: any) => { try { setTransactionsLoading(true); setTransactionsError(null); const params: Record = { ..._scopeParams }; 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; if (data && typeof data === 'object' && 'items' in data) { setTransactions(Array.isArray(data.items) ? data.items : []); if (data.pagination) { setTransactionsPagination(data.pagination); } } else { setTransactions(Array.isArray(data) ? data : []); } } catch (err: any) { console.error('Failed to load transactions:', err); setTransactionsError(err.response?.data?.detail || err.message || t('Fehler beim Laden der Transaktionen')); } finally { setTransactionsLoading(false); } }, [_scopeParams, t]); const _fetchTransactionFilterValues = useCallback(async ( columnKey: string, crossFilters?: Record, ): Promise => { const params: Record = { column: columnKey, ..._scopeParams, }; if (crossFilters && Object.keys(crossFilters).length > 0) { params.pagination = JSON.stringify({ filters: crossFilters }); } const resp = await api.get('/api/billing/view/users/transactions/filter-values', { params }); return Array.isArray(resp.data) ? resp.data : []; }, [_scopeParams]); const transactionsHookData = useMemo(() => ({ refetch: _loadTransactions, pagination: transactionsPagination || undefined, fetchFilterValues: _fetchTransactionFilterValues, }), [_loadTransactions, transactionsPagination, _fetchTransactionFilterValues]); // Table column definitions const columns: ColumnConfig[] = useMemo(() => [ { key: 'createdAt', label: t('Datum'), type: 'timestamp' as any, sortable: true, width: 160 }, { key: 'mandateName', label: t('Mandant'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 }, { key: 'userName', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 }, { key: 'transactionType', label: t('Typ'), type: 'text' as any, sortable: true, filterable: true, width: 100 }, { key: 'description', label: t('Beschreibung'), type: 'text' as any, searchable: true, width: 250 }, { key: 'aicoreProvider', label: t('Anbieter'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, { key: 'aicoreModel', label: t('Modell'), type: 'text' as any, sortable: true, filterable: true, width: 150 }, { key: 'featureCode', label: t('Feature'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, { key: 'amount', label: t('Betrag (CHF)'), type: 'number' as any, sortable: true, width: 120 }, ], [t]); const totalBalance = useMemo(() => { const filtered = selectedScope === 'personal' || selectedScope === 'all' ? balances : balances.filter(b => b.mandateId === selectedScope); return filtered.reduce((sum, b) => sum + (b.balance || 0), 0); }, [balances, selectedScope]); const totalStorageMB = useMemo(() => { return storageData.reduce((sum, s) => sum + (s.usedMB || 0), 0); }, [storageData]); const overviewSections = useMemo(() => { if (!viewStats) return []; return _buildOverviewSections(viewStats, totalBalance, totalStorageMB, t); }, [viewStats, totalBalance, totalStorageMB, t]); const diagramSections = useMemo(() => { if (!viewStats) return []; return _buildDiagramSections(viewStats, chartMode, t); }, [viewStats, chartMode, t]); // 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: t('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: t('Mandant: {name}', { name: b.mandateName }) }); } } options.push({ value: 'all', label: t('Alle (RBAC)') }); return options; }, [balances, t]); return (

{t('billingData.statistiken')}

{t('billingData.nutzungDiagrammeUndTransaktionen')}

{checkoutMessage && (
{checkoutMessage.text} {(successParam || canceledParam) && ( )}
)} {/* ================================================================ */} {/* Tab: Übersicht (KPI overview) */} {/* ================================================================ */} {activeTab === 'overview' && (
)} {/* ================================================================ */} {/* Tab: Diagramme */} {/* ================================================================ */} {activeTab === 'diagrams' && (
)} {/* ================================================================ */} {/* Tab: Transaktionen */} {/* ================================================================ */} {activeTab === 'transactions' && (
{transactionsError && (
{transactionsError}
)}
)}
); }; export default BillingDataView;