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,11 +266,18 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
export const BillingDataView: React.FC = () => { export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('overview'); const [activeTab, setActiveTab] = useState<TabType>('overview');
// Scope filter: 'personal' | 'all' | mandateId
const [selectedScope, setSelectedScope] = useState<string>('personal');
// Dashboard state (for Overview tab) // Dashboard state (for Overview tab)
const { const {
balances, balances,
loading: dashboardLoading, loading: dashboardLoading,
} = useBilling(); } = 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) // Statistics state (shared by Overview and Statistics tabs)
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null); const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
@ -282,6 +289,19 @@ export const BillingDataView: React.FC = () => {
const [transactionsError, setTransactionsError] = useState<string | null>(null); const [transactionsError, setTransactionsError] = useState<string | null>(null);
const [transactionsPagination, setTransactionsPagination] = useState<any>(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 // Load aggregated statistics from the view/statistics route
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => { const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
try { try {
@ -290,6 +310,15 @@ export const BillingDataView: React.FC = () => {
if (period === 'day' && month) { if (period === 'day' && month) {
params.month = 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 }); const response = await api.get('/api/billing/view/statistics', { params });
setViewStats(response.data); setViewStats(response.data);
} catch (err: any) { } catch (err: any) {
@ -298,7 +327,7 @@ export const BillingDataView: React.FC = () => {
} finally { } finally {
setStatsLoading(false); setStatsLoading(false);
} }
}, []); }, [selectedScope]);
// Handle filter changes from FormGeneratorReport (user changes period/year/month) // Handle filter changes from FormGeneratorReport (user changes period/year/month)
const _handleStatsFilterChange = useCallback((filterState: ReportFilterState) => { const _handleStatsFilterChange = useCallback((filterState: ReportFilterState) => {
@ -313,7 +342,10 @@ export const BillingDataView: React.FC = () => {
if (activeTab === 'overview' || activeTab === 'statistics') { if (activeTab === 'overview' || activeTab === 'statistics') {
_loadViewStatistics('month', new Date().getFullYear()); _loadViewStatistics('month', new Date().getFullYear());
} }
}, [activeTab, _loadViewStatistics]); if (activeTab === 'overview') {
_loadAllUserBalances();
}
}, [activeTab, _loadViewStatistics, _loadAllUserBalances, selectedScope]);
// Load transactions with pagination support // Load transactions with pagination support
const _loadTransactions = useCallback(async (paginationParams?: any) => { const _loadTransactions = useCallback(async (paginationParams?: any) => {
@ -399,11 +431,46 @@ export const BillingDataView: React.FC = () => {
defaultMonth: new Date().getMonth() + 1 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 ( return (
<div className={styles.billingDashboard}> <div className={styles.billingDashboard}>
<header className={styles.pageHeader}> <header className={styles.pageHeader}>
<h1>Billing</h1> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<p className={styles.subtitle}>Guthaben, Statistiken und Transaktionen</p> <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> </header>
<TabNav activeTab={activeTab} onTabChange={setActiveTab} /> <TabNav activeTab={activeTab} onTabChange={setActiveTab} />
@ -411,36 +478,73 @@ export const BillingDataView: React.FC = () => {
{/* ================================================================ */} {/* ================================================================ */}
{/* Tab: Übersicht (My Overview) */} {/* Tab: Übersicht (My Overview) */}
{/* ================================================================ */} {/* ================================================================ */}
{activeTab === 'overview' && ( {activeTab === 'overview' && (() => {
<> // Filter balances and user accounts by scope
{/* Balance Cards */} const filteredBalances = selectedScope === 'personal' || selectedScope === 'all'
<section className={styles.section}> ? balances
<h2 className={styles.sectionTitle}>Mein Guthaben</h2> : balances.filter(b => b.mandateId === selectedScope);
{dashboardLoading ? (
<div className={styles.loadingPlaceholder}>Lade Guthaben...</div> const filteredUserBalances = selectedScope === 'personal'
) : balances.length === 0 ? ( ? [] // personal view: only own balance cards, no other users
<div className={styles.noData}>Keine Abrechnungskonten vorhanden</div> : selectedScope === 'all'
) : ( ? allUserBalances
<div className={styles.balanceGrid}> : allUserBalances.filter(ub => ub.mandateId === selectedScope);
{balances.map((balance) => (
<BalanceCard key={balance.mandateId} balance={balance} />
))}
</div>
)}
</section>
{/* Usage Statistics via FormGeneratorReport (no period selector - always full year) */} return (
<section className={styles.section}> <>
<FormGeneratorReport {/* Balance Cards - own balances */}
title="Nutzungsübersicht" <section className={styles.section}>
loading={statsLoading} <h2 className={styles.sectionTitle}>Mein Guthaben</h2>
sections={overviewSections} {dashboardLoading ? (
noDataMessage="Keine Statistiken verfügbar" <div className={styles.loadingPlaceholder}>Lade Guthaben...</div>
currencyCode="CHF" ) : filteredBalances.length === 0 ? (
/> <div className={styles.noData}>Keine Abrechnungskonten vorhanden</div>
</section> ) : (
</> <div className={styles.balanceGrid}>
)} {filteredBalances.map((balance) => (
<BalanceCard key={balance.mandateId} balance={balance} />
))}
</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) */} {/* Tab: Statistik (Dashboard) */}
@ -474,7 +578,7 @@ export const BillingDataView: React.FC = () => {
<FormGeneratorTable <FormGeneratorTable
data={transactions} data={transactions}
columns={columns} columns={columns}
apiEndpoint="/api/billing/balance" apiEndpoint="/api/billing/view/users/transactions"
loading={transactionsLoading} loading={transactionsLoading}
pagination={true} pagination={true}
pageSize={25} pageSize={25}

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import * as teamsbotApi from '../../../api/teamsbotApi'; 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 { FaPlay, FaSpinner } from 'react-icons/fa';
import styles from './Teamsbot.module.css'; import styles from './Teamsbot.module.css';