270 lines
9 KiB
TypeScript
270 lines
9 KiB
TypeScript
/**
|
|
* Billing Dashboard Page
|
|
*
|
|
* Zeigt Guthaben, Statistiken und Transaktionen für den Benutzer.
|
|
*/
|
|
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { useBilling, type BillingBalance, type UsageReport, type BillingBucketSize } from '../../hooks/useBilling';
|
|
import { BillingNav } from './BillingNav';
|
|
import styles from './Billing.module.css';
|
|
|
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
|
import {
|
|
PeriodPicker,
|
|
resolvePeriod,
|
|
daysInRange,
|
|
type PeriodValue,
|
|
} from '../../components/PeriodPicker';
|
|
|
|
const _DEFAULT_BILLING_PRESET = { kind: 'thisMonth' as const };
|
|
|
|
function _suggestBucketSize(value: PeriodValue): BillingBucketSize {
|
|
const days = daysInRange(value.fromDate, value.toDate);
|
|
if (days <= 62) return 'day';
|
|
if (days <= 24 * 31) return 'month';
|
|
return 'year';
|
|
}
|
|
|
|
// ============================================================================
|
|
// BALANCE CARD COMPONENT
|
|
// ============================================================================
|
|
|
|
interface BalanceCardProps {
|
|
balance: BillingBalance;
|
|
onClick?: () => void;
|
|
}
|
|
|
|
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
|
|
const { t } = useLanguage();
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat('de-CH', {
|
|
style: 'currency',
|
|
currency: 'CHF'
|
|
}).format(amount);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}
|
|
onClick={onClick}
|
|
>
|
|
<div className={styles.balanceHeader}>
|
|
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
|
</div>
|
|
<div className={styles.balanceAmount}>
|
|
{formatCurrency(balance.balance)}
|
|
</div>
|
|
{balance.isWarning && (
|
|
<div className={styles.warningBadge}>
|
|
{t('Niedriges Guthaben')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// STATISTICS CHART COMPONENT
|
|
// ============================================================================
|
|
|
|
interface StatisticsChartProps {
|
|
statistics: UsageReport | null;
|
|
loading?: boolean;
|
|
}
|
|
|
|
const StatisticsChart: React.FC<StatisticsChartProps> = ({ statistics, loading }) => {
|
|
const { t } = useLanguage();
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat('de-CH', {
|
|
style: 'currency',
|
|
currency: 'CHF'
|
|
}).format(amount);
|
|
};
|
|
|
|
if (loading) {
|
|
return <div className={styles.loadingPlaceholder}>{t('Statistiken laden')}</div>;
|
|
}
|
|
|
|
if (!statistics) {
|
|
return <div className={styles.noData}>{t('Keine Statistiken verfügbar')}</div>;
|
|
}
|
|
|
|
// Calculate max cost for bar scaling
|
|
const maxProviderCost = Math.max(...Object.values(statistics.costByProvider), 1);
|
|
|
|
return (
|
|
<div className={styles.statisticsChart}>
|
|
<div className={styles.totalCost}>
|
|
<span className={styles.totalLabel}>{t('Gesamtkosten')}</span>
|
|
<span className={styles.totalAmount}>{formatCurrency(statistics.totalCost)}</span>
|
|
</div>
|
|
|
|
<div className={styles.chartSection}>
|
|
<h4>{t('Kosten nach Anbieter')}</h4>
|
|
{Object.entries(statistics.costByProvider).length === 0 ? (
|
|
<div className={styles.noData}>{t('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>{t('Kosten nach Modell')}</h4>
|
|
{Object.entries(statistics.costByModel || {}).length === 0 ? (
|
|
<div className={styles.noData}>{t('Keine Daten')}</div>
|
|
) : (
|
|
<div className={styles.barChart}>
|
|
{Object.entries(statistics.costByModel || {}).map(([model, cost]) => (
|
|
<div key={model} className={styles.barRow}>
|
|
<span className={styles.barLabel}>{model}</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>{t('Kosten nach Feature')}</h4>
|
|
{Object.entries(statistics.costByFeature).length === 0 ? (
|
|
<div className={styles.noData}>{t('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>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// MAIN COMPONENT
|
|
// ============================================================================
|
|
|
|
export const BillingDashboard: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
const {
|
|
balances,
|
|
statistics,
|
|
loading,
|
|
loadStatistics
|
|
} = useBilling();
|
|
|
|
const [period, setPeriod] = useState<PeriodValue>(() => {
|
|
const r = resolvePeriod(_DEFAULT_BILLING_PRESET);
|
|
return { preset: _DEFAULT_BILLING_PRESET, fromDate: r.fromDate, toDate: r.toDate };
|
|
});
|
|
// Frontend-Heuristik fuer Default; user kann uebersteuern.
|
|
const [bucketSize, setBucketSize] = useState<BillingBucketSize>(() => _suggestBucketSize(period));
|
|
const [bucketUserOverridden, setBucketUserOverridden] = useState(false);
|
|
|
|
const _handlePeriodChange = (next: PeriodValue) => {
|
|
setPeriod(next);
|
|
if (!bucketUserOverridden) {
|
|
setBucketSize(_suggestBucketSize(next));
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void loadStatistics({
|
|
dateFrom: period.fromDate,
|
|
dateTo: period.toDate,
|
|
bucketSize,
|
|
});
|
|
}, [period.fromDate, period.toDate, bucketSize, loadStatistics]);
|
|
|
|
const _bucketLabel = useMemo(() => ({
|
|
day: t('Tag'),
|
|
month: t('Monat'),
|
|
year: t('Jahr'),
|
|
} as Record<BillingBucketSize, string>), [t]);
|
|
|
|
return (
|
|
<div className={styles.billingDashboard}>
|
|
<header className={styles.pageHeader}>
|
|
<h1>{t('Abrechnung')}</h1>
|
|
<p className={styles.subtitle}>{t('Übersicht über Guthaben und Nutzung')}</p>
|
|
</header>
|
|
|
|
<BillingNav />
|
|
|
|
{/* Balance Cards */}
|
|
<section className={styles.section}>
|
|
<h2 className={styles.sectionTitle}>{t('Guthaben')}</h2>
|
|
{loading ? (
|
|
<div className={styles.loadingPlaceholder}>{t('Guthaben laden')}</div>
|
|
) : balances.length === 0 ? (
|
|
<div className={styles.noData}>{t('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}>{t('Nutzungsstatistik')}</h2>
|
|
<div className={styles.periodSelector}>
|
|
<PeriodPicker
|
|
value={period}
|
|
onChange={_handlePeriodChange}
|
|
direction="past"
|
|
defaultPreset={_DEFAULT_BILLING_PRESET}
|
|
enabledPresets={[
|
|
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
|
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
|
|
]}
|
|
/>
|
|
<label className={styles.bucketLabel}>{t('Gruppierung')}</label>
|
|
<select
|
|
value={bucketSize}
|
|
onChange={(e) => {
|
|
setBucketSize(e.target.value as BillingBucketSize);
|
|
setBucketUserOverridden(true);
|
|
}}
|
|
className={styles.select}
|
|
aria-label={t('Gruppierung')}
|
|
>
|
|
<option value="day">{_bucketLabel.day}</option>
|
|
<option value="month">{_bucketLabel.month}</option>
|
|
<option value="year">{_bucketLabel.year}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<StatisticsChart statistics={statistics} loading={loading} />
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BillingDashboard;
|