752 lines
28 KiB
TypeScript
752 lines
28 KiB
TypeScript
/**
|
|
* 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<string, number>;
|
|
costByModel: Record<string, number>;
|
|
costByFeature: Record<string, number>;
|
|
costByMandate: Record<string, number>;
|
|
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<BalanceCardProps> = ({ 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 (
|
|
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
|
<div className={styles.balanceHeader}>
|
|
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
|
<span className={styles.billingModel}>{_getBillingModelLabel(balance.billingModel)}</span>
|
|
</div>
|
|
<div className={styles.balanceAmount}>
|
|
{_formatCurrency(balance.balance)}
|
|
</div>
|
|
{balance.isWarning && (
|
|
<div className={styles.warningBadge}>
|
|
Niedriges Guthaben
|
|
</div>
|
|
)}
|
|
{canTopUp && onCheckout && (
|
|
<div style={{ marginTop: '12px' }}>
|
|
{!showCheckout ? (
|
|
<button
|
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
|
style={{ width: '100%', fontSize: '13px', padding: '6px 12px' }}
|
|
onClick={() => setShowCheckout(true)}
|
|
>
|
|
Budget laden mit Kreditkarte
|
|
</button>
|
|
) : (
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
<select
|
|
className={styles.select}
|
|
value={selectedAmount}
|
|
onChange={(e) => setSelectedAmount(Number(e.target.value))}
|
|
style={{ flex: 1, fontSize: '13px' }}
|
|
>
|
|
{STRIPE_AMOUNT_PRESETS.map((preset) => (
|
|
<option key={preset} value={preset}>{preset} CHF</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
|
style={{ fontSize: '13px', padding: '6px 12px', whiteSpace: 'nowrap' }}
|
|
disabled={checkoutLoading}
|
|
onClick={() => onCheckout(balance.mandateId, selectedAmount)}
|
|
>
|
|
{checkoutLoading ? 'Laden...' : 'Zahlen'}
|
|
</button>
|
|
<button
|
|
className={`${styles.button} ${styles.buttonSecondary || ''}`}
|
|
style={{ fontSize: '13px', padding: '6px 12px' }}
|
|
onClick={() => setShowCheckout(false)}
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// TAB NAVIGATION COMPONENT
|
|
// ============================================================================
|
|
|
|
type TabType = 'overview' | 'statistics' | 'transactions';
|
|
|
|
interface TabNavProps {
|
|
activeTab: TabType;
|
|
onTabChange: (tab: TabType) => void;
|
|
}
|
|
|
|
const TabNav: React.FC<TabNavProps> = ({ 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 (
|
|
<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')}>
|
|
Übersicht
|
|
</button>
|
|
<button onClick={() => onTabChange('statistics')} style={_navLinkStyle(activeTab === 'statistics')}>
|
|
Statistik
|
|
</button>
|
|
<button onClick={() => onTabChange('transactions')} style={_navLinkStyle(activeTab === 'transactions')}>
|
|
Transaktionen
|
|
</button>
|
|
</nav>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// HELPERS: Convert viewStats to ReportSection arrays
|
|
// ============================================================================
|
|
|
|
function _recordToChartData(record: Record<string, number>): 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<TabType>('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<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: '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<any[]>([]);
|
|
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
|
|
|
|
// Statistics state (shared by Overview and Statistics tabs)
|
|
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
|
|
const [statsLoading, setStatsLoading] = 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);
|
|
|
|
// 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<ReportSection[]>(() => {
|
|
if (!viewStats) return [];
|
|
return _buildOverviewSections(viewStats);
|
|
}, [viewStats]);
|
|
|
|
const statisticsSections = useMemo<ReportSection[]>(() => {
|
|
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<string>();
|
|
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 (
|
|
<div className={styles.billingDashboard}>
|
|
<header className={styles.pageHeader}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<div>
|
|
<h1>Billing</h1>
|
|
<p className={styles.subtitle}>Guthaben, Statistiken und Transaktionen</p>
|
|
</div>
|
|
{activeTab !== 'transactions' && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
<label style={{ fontSize: '13px', opacity: 0.7 }}>Ansicht:</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>
|
|
)}
|
|
</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' }}>Schliessen</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ================================================================ */}
|
|
{/* 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 */}
|
|
<section className={styles.section}>
|
|
<h2 className={styles.sectionTitle}>Mein Guthaben</h2>
|
|
{dashboardLoading ? (
|
|
<div className={styles.loadingPlaceholder}>Lade Guthaben...</div>
|
|
) : filteredBalances.length === 0 ? (
|
|
<div className={styles.noData}>Keine Abrechnungskonten vorhanden</div>
|
|
) : (
|
|
<div className={styles.balanceGrid}>
|
|
{filteredBalances.map((balance) => (
|
|
<BalanceCard
|
|
key={balance.mandateId}
|
|
balance={balance}
|
|
onCheckout={_handleCheckout}
|
|
checkoutLoading={checkoutLoading}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* All User Balance Cards (mandate/all scope) */}
|
|
{filteredUserBalances.length > 0 && (
|
|
<section className={styles.section}>
|
|
<h2 className={styles.sectionTitle}>Benutzer-Guthaben</h2>
|
|
{allUserBalancesLoading ? (
|
|
<div className={styles.loadingPlaceholder}>Lade Benutzer-Guthaben...</div>
|
|
) : (
|
|
<div className={styles.balanceGrid}>
|
|
{filteredUserBalances.map((ub, idx) => (
|
|
<div key={`${ub.userId}-${ub.mandateId}-${idx}`} className={styles.balanceCard}>
|
|
<div className={styles.balanceHeader}>
|
|
<h3 className={styles.mandateName}>{ub.userName || ub.userId?.slice(0, 8)}</h3>
|
|
<span className={styles.billingModel}>{ub.mandateName}</span>
|
|
</div>
|
|
<div className={styles.balanceAmount}>
|
|
{_formatCurrency(ub.balance || 0)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* Usage Statistics via FormGeneratorReport */}
|
|
<section className={styles.section}>
|
|
<FormGeneratorReport
|
|
title={selectedScope === 'personal' ? 'Meine Nutzung' : 'Nutzungsübersicht'}
|
|
loading={statsLoading}
|
|
sections={overviewSections}
|
|
noDataMessage="Keine Statistiken verfügbar"
|
|
currencyCode="CHF"
|
|
/>
|
|
</section>
|
|
</>
|
|
);
|
|
})()}
|
|
|
|
{/* ================================================================ */}
|
|
{/* Tab: Statistik (Dashboard) */}
|
|
{/* ================================================================ */}
|
|
{activeTab === 'statistics' && (
|
|
<section className={styles.section}>
|
|
<FormGeneratorReport
|
|
title="Nutzungsstatistik"
|
|
subtitle="Detaillierte Analyse der AI-Nutzung"
|
|
periodSelector={periodSelectorConfig}
|
|
onFilterChange={_handleStatsFilterChange}
|
|
loading={statsLoading}
|
|
sections={statisticsSections}
|
|
noDataMessage="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
|
|
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="Keine Transaktionen vorhanden"
|
|
onRefresh={_loadTransactions}
|
|
hookData={transactionsHookData}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BillingDataView;
|