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:
parent
c58bc77154
commit
40fe8a0a31
2 changed files with 139 additions and 35 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue