/** * 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, ReportDateRangeSelectorConfig } from '../../components/FormGenerator/FormGeneratorReport'; import api from '../../api'; import { useBilling, type BillingBucketSize } from '../../hooks/useBilling'; import { UserTransaction } from '../../api/billingApi'; import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize'; import { useLanguage } from '../../providers/language/LanguageContext'; import { daysInRange, resolvePeriod, toIsoDate, type PeriodValue, } from '../../components/PeriodPicker'; import styles from './Billing.module.css'; const _DEFAULT_STATS_PRESET = { kind: 'thisMonth' as const }; function _suggestBucketSize(fromIso: string, toIso: string): BillingBucketSize { const days = daysInRange(fromIso, toIso); if (days <= 62) return 'day'; if (days <= 24 * 31) return 'month'; return 'year'; } function _initialStatsPeriod(): PeriodValue { const r = resolvePeriod(_DEFAULT_STATS_PRESET); return { preset: _DEFAULT_STATS_PRESET, fromDate: r.fromDate, toDate: r.toDate }; } 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, t: TranslateFn): ReportChartDataPoint[] { return Object.entries(record) .sort((a, b) => b[1] - a[1]) .map(([key, value]) => ({ key: key || t('—'), value })); } function _buildOverviewSections( viewStats: ViewStatistics, totalBalance: number, totalStorageMB: number, t: 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: t('Gesamtkosten'), value: _formatCurrency(viewStats.totalCost), subtitle: t('{n} Transaktionen', { n: String(viewStats.transactionCount) }), }, { label: t('Anbieter'), value: Object.keys(viewStats.costByProvider).length, subtitle: topProvider ? t('Top: {name}', { name: topProvider[0] }) : t('Keine Nutzung'), }, { label: t('Modelle'), value: Object.keys(viewStats.costByModel || {}).length, subtitle: topModel ? t('Top: {name}', { name: topModel[0] }) : t('Keine Nutzung'), }, { label: t('Features'), value: Object.keys(viewStats.costByFeature).length, subtitle: topFeature ? t('Top: {name}', { name: topFeature[0] }) : t('Keine Nutzung'), }, { label: t('Guthaben'), value: _formatCurrency(totalBalance), }, { label: t('Speicher'), value: formatBinaryDataSizeFromMebibytes(totalStorageMB), }, ] }, ]; } function _buildDiagramSections(viewStats: ViewStatistics, chartMode: 'pie' | 'bar', t: 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: t('Gesamtkosten'), value: _formatCurrency(viewStats.totalCost), subtitle: t('{n} Transaktionen', { n: String(viewStats.transactionCount) }) }, { label: t('Durchschnitt'), value: _formatCurrency(avgCost), subtitle: t('pro Transaktion') }, { label: t('Anbieter'), value: Object.keys(viewStats.costByProvider).length }, { label: t('Modelle'), value: Object.keys(viewStats.costByModel || {}).length }, { label: t('Features'), value: Object.keys(viewStats.costByFeature).length }, { label: t('Mandanten'), value: Object.keys(viewStats.costByMandate).length }, ] }, { type: 'barChart', title: t('Kostenentwicklung'), data: timeSeriesData, formatValue: _formatCurrency, span: 'full' as const }, { type: chartType, title: t('Kosten nach Anbieter'), data: _recordToChartData(viewStats.costByProvider, t), formatValue: _formatCurrency, donut: chartMode === 'pie', span: 'half' as const }, { type: chartType, title: t('Kosten nach Modell'), data: _recordToChartData(viewStats.costByModel || {}, t), formatValue: _formatCurrency, donut: chartMode === 'pie', span: 'half' as const }, { type: chartType, title: t('Kosten nach Feature'), data: _recordToChartData(viewStats.costByFeature, t), formatValue: _formatCurrency, donut: chartMode === 'pie', span: 'half' as const }, { type: chartType, title: t('Kosten nach Mandant'), data: _recordToChartData(viewStats.costByMandate, t), 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: t('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 // "nur meine Daten" is an additional filter on top of the dropdown scope const _scopeParams = useMemo((): Record => { const params: Record = {}; if (selectedScope === 'personal') { params.scope = 'personal'; } else if (selectedScope === 'all') { params.scope = 'all'; } else { params.scope = 'mandate'; params.mandateId = selectedScope; } if (onlyMyData) { params.onlyMine = 'true'; } return params; }, [selectedScope, onlyMyData]); const [statsPeriod, setStatsPeriod] = useState(() => _initialStatsPeriod()); const [statsBucketSize, setStatsBucketSize] = useState(() => { const init = _initialStatsPeriod(); return _suggestBucketSize(init.fromDate, init.toDate); }); const [bucketUserOverridden, setBucketUserOverridden] = useState(false); const _loadViewStatistics = useCallback(async ( range: { dateFrom: string; dateTo: string; bucketSize: BillingBucketSize }, ) => { try { setStatsLoading(true); const params: Record = { dateFrom: range.dateFrom, dateTo: range.dateTo, bucketSize: range.bucketSize, ..._scopeParams, }; 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 PeriodPicker change coming back from the shared `dateRangeSelector` // of `FormGeneratorReport`. Prefer the full `periodValue` so we keep the // original preset (e.g. `thisMonth`) instead of collapsing to `custom`. const _handleStatsFilterChange = useCallback((filterState: ReportFilterState) => { let next: PeriodValue | null = null; if (filterState.periodValue) { next = filterState.periodValue; } else if (filterState.dateRange) { next = { preset: { kind: 'custom' }, fromDate: toIsoDate(filterState.dateRange.from), toDate: toIsoDate(filterState.dateRange.to), }; } if (!next) return; setStatsPeriod(next); if (!bucketUserOverridden) { setStatsBucketSize(_suggestBucketSize(next.fromDate, next.toDate)); } }, [bucketUserOverridden]); // 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 params: Record = {}; if (onlyMyData) params.onlyMine = 'true'; const resp = await api.get(`/api/subscription/data-volume/${mid}`, { params }); 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, onlyMyData]); // Initial / reactive load: any change to period / bucketSize / scope reloads. useEffect(() => { if (activeTab === 'overview' || activeTab === 'diagrams') { void _loadViewStatistics({ dateFrom: statsPeriod.fromDate, dateTo: statsPeriod.toDate, bucketSize: statsBucketSize, }); _loadStorageData(); } }, [activeTab, statsPeriod.fromDate, statsPeriod.toDate, statsBucketSize, _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', { params: { ...params, mode: 'filterValues' } }); 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, searchable: 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]); // Date-range selector config: use shared PeriodPicker via FormGeneratorReport. const dateRangeSelectorConfig = useMemo(() => ({ enabled: true, direction: 'past', defaultPresetKind: 'thisMonth', enabledPresets: [ 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter', 'ytd', 'lastYear', 'last12Months', 'lastN', 'custom', ], }), []); // 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('Statistiken')}

{t('Nutzung, Diagramme und Transaktionen')}

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