frontend_nyla/src/pages/billing/BillingDataView.tsx
ValueOn AG b4574b6a2e fixes
2026-04-21 00:54:57 +02:00

739 lines
28 KiB
TypeScript

/**
* 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, string | number>) => 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<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;
}
// ============================================================================
// TAB NAVIGATION COMPONENT
// ============================================================================
type TabType = 'overview' | 'diagrams' | 'transactions';
interface TabNavProps {
activeTab: TabType;
onTabChange: (tab: TabType) => void;
}
const TabNav: React.FC<TabNavProps> = ({ 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 (
<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')}>
{t('Übersicht')}
</button>
<button onClick={() => onTabChange('diagrams')} style={_navLinkStyle(activeTab === 'diagrams')}>
{t('Diagramme')}
</button>
<button onClick={() => onTabChange('transactions')} style={_navLinkStyle(activeTab === 'transactions')}>
{t('Transaktionen')}
</button>
</nav>
);
};
// ============================================================================
// HELPERS: Convert viewStats to ReportSection arrays
// ============================================================================
function _recordToChartData(record: Record<string, number>, 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<TabType>('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<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: 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<ViewStatistics | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
// Storage volume state (for Statistics tab)
const [storageData, setStorageData] = useState<DataVolumeInfo[]>([]);
const [, 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);
// 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<string, string> => {
const params: Record<string, string> = {};
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<PeriodValue>(() => _initialStatsPeriod());
const [statsBucketSize, setStatsBucketSize] = useState<BillingBucketSize>(() => {
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<string, string | number> = {
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<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 params: Record<string, string> = {};
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<string, string> = { ..._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<string, any>,
): Promise<string[]> => {
const params: Record<string, string> = {
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<ReportSection[]>(() => {
if (!viewStats) return [];
return _buildOverviewSections(viewStats, totalBalance, totalStorageMB, t);
}, [viewStats, totalBalance, totalStorageMB, t]);
const diagramSections = useMemo<ReportSection[]>(() => {
if (!viewStats) return [];
return _buildDiagramSections(viewStats, chartMode, t);
}, [viewStats, chartMode, t]);
// Date-range selector config: use shared PeriodPicker via FormGeneratorReport.
const dateRangeSelectorConfig = useMemo<ReportDateRangeSelectorConfig>(() => ({
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<string>();
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 (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1>{t('Statistiken')}</h1>
<p className={styles.subtitle}>{t('Nutzung, Diagramme und Transaktionen')}</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<label style={{ fontSize: '13px', opacity: 0.7 }}>{t('Kontext:')}</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>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={onlyMyData}
onChange={(e) => setOnlyMyData(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
{t('nur meine Daten')}
</label>
</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' }}>{t('Schließen')}</button>
)}
</div>
)}
{/* ================================================================ */}
{/* Tab: Übersicht (KPI overview) */}
{/* ================================================================ */}
{activeTab === 'overview' && (
<section className={styles.section}>
<FormGeneratorReport
loading={statsLoading || dashboardLoading}
sections={overviewSections}
noDataMessage={t('Keine Statistiken verfügbar')}
currencyCode="CHF"
/>
</section>
)}
{/* ================================================================ */}
{/* Tab: Diagramme */}
{/* ================================================================ */}
{activeTab === 'diagrams' && (
<section className={styles.section}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
<div style={{
display: 'inline-flex',
borderRadius: '6px',
overflow: 'hidden',
border: '1px solid var(--border-color, #333)',
}}>
<button
onClick={() => setChartMode('pie')}
style={{
padding: '6px 14px',
fontSize: '13px',
border: 'none',
cursor: 'pointer',
background: chartMode === 'pie' ? 'var(--primary-color, #F25843)' : 'var(--bg-secondary, #2a2a2a)',
color: chartMode === 'pie' ? '#fff' : 'var(--color-text, #e0e0e0)',
fontWeight: chartMode === 'pie' ? 600 : 400,
}}
>
{t('Kreis')}
</button>
<button
onClick={() => setChartMode('bar')}
style={{
padding: '6px 14px',
fontSize: '13px',
border: 'none',
cursor: 'pointer',
background: chartMode === 'bar' ? 'var(--primary-color, #F25843)' : 'var(--bg-secondary, #2a2a2a)',
color: chartMode === 'bar' ? '#fff' : 'var(--color-text, #e0e0e0)',
fontWeight: chartMode === 'bar' ? 600 : 400,
}}
>
{t('Balken')}
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<label style={{ fontSize: '13px', opacity: 0.7 }}>{t('Gruppierung')}</label>
<select
className={styles.select || ''}
value={statsBucketSize}
onChange={(e) => {
setStatsBucketSize(e.target.value as BillingBucketSize);
setBucketUserOverridden(true);
}}
aria-label={t('Gruppierung')}
>
<option value="day">{t('Tag')}</option>
<option value="month">{t('Monat')}</option>
<option value="year">{t('Jahr')}</option>
</select>
</div>
<FormGeneratorReport
dateRangeSelector={dateRangeSelectorConfig}
onFilterChange={_handleStatsFilterChange}
loading={statsLoading}
sections={diagramSections}
noDataMessage={t('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
key={`txn-${_scopeParams.scope}-${_scopeParams.mandateId ?? ''}-${onlyMyData}`}
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={t('Keine Transaktionen vorhanden')}
onRefresh={_loadTransactions}
hookData={transactionsHookData}
/>
</div>
)}
</div>
);
};
export default BillingDataView;