feat(billing): scope filter, user balance cards, CSV export fix

- Scope dropdown (Meine Kosten / Mandant / Alle) on Overview and Statistics tabs
- All user balance cards shown in Overview when scope is mandate or all
- Balance cards and statistics react to scope selection
- CSV export endpoint fixed: /api/billing/view/users/transactions
- Scope selector hidden on Transactions tab (FormGeneratorTable handles own filters)
- Fixed unused VoiceLanguage import in TeamsbotSettingsView

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ValueOn AG 2026-02-15 10:44:25 +01:00
parent c58bc77154
commit 40fe8a0a31
2 changed files with 139 additions and 35 deletions

View file

@ -266,12 +266,19 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('overview');
// Scope filter: 'personal' | 'all' | mandateId
const [selectedScope, setSelectedScope] = useState<string>('personal');
// Dashboard state (for Overview tab)
const {
balances,
loading: dashboardLoading,
} = useBilling();
// 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);
@ -282,6 +289,19 @@ export const BillingDataView: React.FC = () => {
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 {
@ -290,6 +310,15 @@ export const BillingDataView: React.FC = () => {
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) {
@ -298,7 +327,7 @@ export const BillingDataView: React.FC = () => {
} finally {
setStatsLoading(false);
}
}, []);
}, [selectedScope]);
// Handle filter changes from FormGeneratorReport (user changes period/year/month)
const _handleStatsFilterChange = useCallback((filterState: ReportFilterState) => {
@ -313,7 +342,10 @@ export const BillingDataView: React.FC = () => {
if (activeTab === 'overview' || activeTab === 'statistics') {
_loadViewStatistics('month', new Date().getFullYear());
}
}, [activeTab, _loadViewStatistics]);
if (activeTab === 'overview') {
_loadAllUserBalances();
}
}, [activeTab, _loadViewStatistics, _loadAllUserBalances, selectedScope]);
// Load transactions with pagination support
const _loadTransactions = useCallback(async (paginationParams?: any) => {
@ -399,11 +431,46 @@ export const BillingDataView: React.FC = () => {
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} />
@ -411,28 +478,64 @@ export const BillingDataView: React.FC = () => {
{/* ================================================================ */}
{/* Tab: Übersicht (My Overview) */}
{/* ================================================================ */}
{activeTab === '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 */}
{/* Balance Cards - own balances */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Mein Guthaben</h2>
{dashboardLoading ? (
<div className={styles.loadingPlaceholder}>Lade Guthaben...</div>
) : balances.length === 0 ? (
) : filteredBalances.length === 0 ? (
<div className={styles.noData}>Keine Abrechnungskonten vorhanden</div>
) : (
<div className={styles.balanceGrid}>
{balances.map((balance) => (
{filteredBalances.map((balance) => (
<BalanceCard key={balance.mandateId} balance={balance} />
))}
</div>
)}
</section>
{/* Usage Statistics via FormGeneratorReport (no period selector - always full year) */}
{/* 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="Nutzungsübersicht"
title={selectedScope === 'personal' ? 'Meine Nutzung' : 'Nutzungsübersicht'}
loading={statsLoading}
sections={overviewSections}
noDataMessage="Keine Statistiken verfügbar"
@ -440,7 +543,8 @@ export const BillingDataView: React.FC = () => {
/>
</section>
</>
)}
);
})()}
{/* ================================================================ */}
{/* Tab: Statistik (Dashboard) */}
@ -474,7 +578,7 @@ export const BillingDataView: React.FC = () => {
<FormGeneratorTable
data={transactions}
columns={columns}
apiEndpoint="/api/billing/balance"
apiEndpoint="/api/billing/view/users/transactions"
loading={transactionsLoading}
pagination={true}
pageSize={25}

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi';
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceLanguage, VoiceOption } from '../../../api/teamsbotApi';
import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi';
import { FaPlay, FaSpinner } from 'react-icons/fa';
import styles from './Teamsbot.module.css';