443 lines
14 KiB
TypeScript
443 lines
14 KiB
TypeScript
/**
|
|
* BillingDataView
|
|
*
|
|
* Unified billing page with internal tabs:
|
|
* - Tab "Übersicht": Balance cards + Statistics (from BillingDashboard)
|
|
* - Tab "Transaktionen": Transaction table with FormGeneratorTable
|
|
*/
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
|
import api from '../../api';
|
|
import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling';
|
|
import { UserTransaction } from '../../api/billingApi';
|
|
import styles from './Billing.module.css';
|
|
|
|
// ============================================================================
|
|
// BALANCE CARD COMPONENT
|
|
// ============================================================================
|
|
|
|
interface BalanceCardProps {
|
|
balance: BillingBalance;
|
|
}
|
|
|
|
const BalanceCard: React.FC<BalanceCardProps> = ({ balance }) => {
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat('de-CH', {
|
|
style: 'currency',
|
|
currency: 'CHF'
|
|
}).format(amount);
|
|
};
|
|
|
|
const getBillingModelLabel = (model: string) => {
|
|
switch (model) {
|
|
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
|
|
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
|
|
case 'CREDIT_POSTPAY': return 'Kredit';
|
|
case 'UNLIMITED': return 'Unlimited';
|
|
default: return model;
|
|
}
|
|
};
|
|
|
|
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>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// STATISTICS CHART COMPONENT
|
|
// ============================================================================
|
|
|
|
interface StatisticsChartProps {
|
|
statistics: UsageReport | null;
|
|
loading?: boolean;
|
|
}
|
|
|
|
const StatisticsChart: React.FC<StatisticsChartProps> = ({ statistics, loading }) => {
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat('de-CH', {
|
|
style: 'currency',
|
|
currency: 'CHF'
|
|
}).format(amount);
|
|
};
|
|
|
|
if (loading) {
|
|
return <div className={styles.loadingPlaceholder}>Lade Statistiken...</div>;
|
|
}
|
|
|
|
if (!statistics) {
|
|
return <div className={styles.noData}>Keine Statistiken verfügbar</div>;
|
|
}
|
|
|
|
const maxProviderCost = Math.max(...Object.values(statistics.costByProvider), 1);
|
|
|
|
return (
|
|
<div className={styles.statisticsChart}>
|
|
<div className={styles.totalCost}>
|
|
<span className={styles.totalLabel}>Gesamtkosten</span>
|
|
<span className={styles.totalAmount}>{formatCurrency(statistics.totalCost)}</span>
|
|
</div>
|
|
|
|
<div className={styles.chartSection}>
|
|
<h4>Kosten nach Anbieter</h4>
|
|
{Object.entries(statistics.costByProvider).length === 0 ? (
|
|
<div className={styles.noData}>Keine Daten</div>
|
|
) : (
|
|
<div className={styles.barChart}>
|
|
{Object.entries(statistics.costByProvider).map(([provider, cost]) => (
|
|
<div key={provider} className={styles.barRow}>
|
|
<span className={styles.barLabel}>{provider}</span>
|
|
<div className={styles.barContainer}>
|
|
<div
|
|
className={styles.bar}
|
|
style={{ width: `${(cost / maxProviderCost) * 100}%` }}
|
|
/>
|
|
</div>
|
|
<span className={styles.barValue}>{formatCurrency(cost)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.chartSection}>
|
|
<h4>Kosten nach Feature</h4>
|
|
{Object.entries(statistics.costByFeature).length === 0 ? (
|
|
<div className={styles.noData}>Keine Daten</div>
|
|
) : (
|
|
<div className={styles.featureList}>
|
|
{Object.entries(statistics.costByFeature).map(([feature, cost]) => (
|
|
<div key={feature} className={styles.featureRow}>
|
|
<span className={styles.featureLabel}>{feature}</span>
|
|
<span className={styles.featureValue}>{formatCurrency(cost)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// TAB NAVIGATION COMPONENT
|
|
// ============================================================================
|
|
|
|
type TabType = 'overview' | '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('transactions')}
|
|
style={navLinkStyle(activeTab === 'transactions')}
|
|
>
|
|
Transaktionen
|
|
</button>
|
|
</nav>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// MAIN COMPONENT
|
|
// ============================================================================
|
|
|
|
export const BillingDataView: React.FC = () => {
|
|
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
|
|
|
// Dashboard state (for Overview tab)
|
|
const {
|
|
balances,
|
|
statistics,
|
|
loading: dashboardLoading,
|
|
loadStatistics
|
|
} = useBilling();
|
|
const [selectedPeriod, setSelectedPeriod] = useState<'month' | 'year'>('month');
|
|
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
|
|
const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1);
|
|
|
|
// Transactions state (for Transactions tab)
|
|
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
|
|
const [transactionsLoading, setTransactionsLoading] = useState(false);
|
|
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
|
|
|
// Load statistics when period changes
|
|
useEffect(() => {
|
|
if (selectedPeriod === 'month') {
|
|
loadStatistics('month', selectedYear);
|
|
} else {
|
|
loadStatistics('year', selectedYear);
|
|
}
|
|
}, [selectedPeriod, selectedYear, loadStatistics]);
|
|
|
|
// Load transactions
|
|
const loadTransactions = useCallback(async () => {
|
|
try {
|
|
setTransactionsLoading(true);
|
|
setTransactionsError(null);
|
|
const response = await api.get('/api/billing/view/users/transactions', {
|
|
params: { limit: 500 }
|
|
});
|
|
setTransactions(response.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' && transactions.length === 0) {
|
|
loadTransactions();
|
|
}
|
|
}, [activeTab, transactions.length, loadTransactions]);
|
|
|
|
// Available years
|
|
const availableYears = useMemo(() => {
|
|
const current = new Date().getFullYear();
|
|
return [current, current - 1, current - 2];
|
|
}, []);
|
|
|
|
// Available months
|
|
const availableMonths = [
|
|
{ value: 1, label: 'Januar' },
|
|
{ value: 2, label: 'Februar' },
|
|
{ value: 3, label: 'März' },
|
|
{ value: 4, label: 'April' },
|
|
{ value: 5, label: 'Mai' },
|
|
{ value: 6, label: 'Juni' },
|
|
{ value: 7, label: 'Juli' },
|
|
{ value: 8, label: 'August' },
|
|
{ value: 9, label: 'September' },
|
|
{ value: 10, label: 'Oktober' },
|
|
{ value: 11, label: 'November' },
|
|
{ value: 12, label: 'Dezember' },
|
|
];
|
|
|
|
// Transform transactions for table display
|
|
const tableData = useMemo(() => {
|
|
return transactions.map((t, index) => ({
|
|
_uniqueId: `${t.id}-${t.mandateId}-${index}`,
|
|
id: t.id,
|
|
createdAt: t.createdAt,
|
|
mandateId: t.mandateId,
|
|
mandateName: t.mandateName || '-',
|
|
userId: t.userId,
|
|
userName: t.userName || '-',
|
|
transactionType: t.transactionType,
|
|
description: t.description || '-',
|
|
aicoreProvider: t.aicoreProvider || '-',
|
|
featureCode: t.featureCode || '-',
|
|
amount: t.transactionType === 'DEBIT' ? -t.amount : t.amount,
|
|
}));
|
|
}, [transactions]);
|
|
|
|
// Table column definitions
|
|
const columns: ColumnConfig[] = useMemo(() => [
|
|
{
|
|
key: 'createdAt',
|
|
label: 'Datum',
|
|
type: 'datetime',
|
|
sortable: true,
|
|
width: 160,
|
|
},
|
|
{
|
|
key: 'mandateName',
|
|
label: 'Mandant',
|
|
type: 'text',
|
|
sortable: true,
|
|
filterable: true,
|
|
searchable: true,
|
|
width: 150,
|
|
},
|
|
{
|
|
key: 'userName',
|
|
label: 'Benutzer',
|
|
type: 'text',
|
|
sortable: true,
|
|
filterable: true,
|
|
searchable: true,
|
|
width: 150,
|
|
},
|
|
{
|
|
key: 'transactionType',
|
|
label: 'Typ',
|
|
type: 'text',
|
|
sortable: true,
|
|
filterable: true,
|
|
filterOptions: ['CREDIT', 'DEBIT', 'ADJUSTMENT'],
|
|
width: 100,
|
|
},
|
|
{
|
|
key: 'description',
|
|
label: 'Beschreibung',
|
|
type: 'text',
|
|
searchable: true,
|
|
width: 250,
|
|
},
|
|
{
|
|
key: 'aicoreProvider',
|
|
label: 'Anbieter',
|
|
type: 'text',
|
|
sortable: true,
|
|
filterable: true,
|
|
width: 120,
|
|
},
|
|
{
|
|
key: 'featureCode',
|
|
label: 'Feature',
|
|
type: 'text',
|
|
sortable: true,
|
|
filterable: true,
|
|
width: 120,
|
|
},
|
|
{
|
|
key: 'amount',
|
|
label: 'Betrag (CHF)',
|
|
type: 'number',
|
|
sortable: true,
|
|
width: 120,
|
|
},
|
|
], []);
|
|
|
|
return (
|
|
<div className={styles.billingDashboard}>
|
|
<header className={styles.pageHeader}>
|
|
<h1>Billing</h1>
|
|
<p className={styles.subtitle}>Guthaben, Statistiken und Transaktionen</p>
|
|
</header>
|
|
|
|
<TabNav activeTab={activeTab} onTabChange={setActiveTab} />
|
|
|
|
{/* Overview Tab */}
|
|
{activeTab === 'overview' && (
|
|
<>
|
|
{/* Balance Cards */}
|
|
<section className={styles.section}>
|
|
<h2 className={styles.sectionTitle}>Guthaben</h2>
|
|
{dashboardLoading ? (
|
|
<div className={styles.loadingPlaceholder}>Lade Guthaben...</div>
|
|
) : balances.length === 0 ? (
|
|
<div className={styles.noData}>Keine Abrechnungskonten vorhanden</div>
|
|
) : (
|
|
<div className={styles.balanceGrid}>
|
|
{balances.map((balance) => (
|
|
<BalanceCard key={balance.mandateId} balance={balance} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Statistics */}
|
|
<section className={styles.section}>
|
|
<div className={styles.sectionHeader}>
|
|
<h2 className={styles.sectionTitle}>Nutzungsstatistik</h2>
|
|
<div className={styles.periodSelector}>
|
|
<select
|
|
value={selectedPeriod}
|
|
onChange={(e) => setSelectedPeriod(e.target.value as 'month' | 'year')}
|
|
className={styles.select}
|
|
>
|
|
<option value="month">Monat</option>
|
|
<option value="year">Jahr</option>
|
|
</select>
|
|
<select
|
|
value={selectedYear}
|
|
onChange={(e) => setSelectedYear(Number(e.target.value))}
|
|
className={styles.select}
|
|
>
|
|
{availableYears.map((year) => (
|
|
<option key={year} value={year}>{year}</option>
|
|
))}
|
|
</select>
|
|
{selectedPeriod === 'month' && (
|
|
<select
|
|
value={selectedMonth}
|
|
onChange={(e) => setSelectedMonth(Number(e.target.value))}
|
|
className={styles.select}
|
|
>
|
|
{availableMonths.map((month) => (
|
|
<option key={month.value} value={month.value}>{month.label}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<StatisticsChart statistics={statistics} loading={dashboardLoading} />
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
{/* Transactions Tab */}
|
|
{activeTab === 'transactions' && (
|
|
<>
|
|
{transactionsError && (
|
|
<div className={styles.errorMessage}>
|
|
{transactionsError}
|
|
</div>
|
|
)}
|
|
|
|
<FormGeneratorTable
|
|
data={tableData}
|
|
columns={columns}
|
|
loading={transactionsLoading}
|
|
pagination={true}
|
|
pageSize={25}
|
|
searchable={true}
|
|
filterable={true}
|
|
sortable={true}
|
|
idField="_uniqueId"
|
|
emptyMessage="Keine Transaktionen vorhanden"
|
|
onRefresh={loadTransactions}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BillingDataView;
|