billing rbac

This commit is contained in:
ValueOn AG 2026-02-08 16:14:06 +01:00
parent 8f29bdb270
commit 15c93b3bf0
8 changed files with 77 additions and 26 deletions

View file

@ -36,11 +36,16 @@ export interface BillingTransaction {
description: string; description: string;
referenceType?: ReferenceType; referenceType?: ReferenceType;
workflowId?: string; workflowId?: string;
featureInstanceId?: string;
featureCode?: string; featureCode?: string;
aicoreProvider?: string; aicoreProvider?: string;
aicoreModel?: string;
createdByUserId?: string;
createdAt?: string; createdAt?: string;
mandateId?: string; mandateId?: string;
mandateName?: string; mandateName?: string;
userId?: string;
userName?: string;
} }
export interface BillingSettings { export interface BillingSettings {
@ -70,6 +75,7 @@ export interface UsageReport {
totalCost: number; totalCost: number;
transactionCount: number; transactionCount: number;
costByProvider: Record<string, number>; costByProvider: Record<string, number>;
costByModel: Record<string, number>;
costByFeature: Record<string, number>; costByFeature: Record<string, number>;
} }
@ -260,6 +266,7 @@ export async function fetchTransactionsAdmin(
*/ */
export interface MandateUserSummary { export interface MandateUserSummary {
id: string; id: string;
username?: string;
email?: string; email?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;

View file

@ -137,21 +137,19 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
} }
}, [isExpanded, handleClickOutside]); }, [isExpanded, handleClickOutside]);
// Check if all providers are selected (or none selected = all used) // Check if all providers are explicitly selected
const isAllSelected = selectedProviders.length === 0 || const isAllSelected = allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length;
(allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length);
// For checkbox display: if none selected, show all as checked (since all are used) // Check if no providers are selected (= no restriction, all allowed by default)
const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders; const isNoneSelected = selectedProviders.length === 0;
const handleToggle = (provider: string) => { const handleToggle = (provider: string) => {
// Use effectiveSelection for toggle logic (handles empty = all case) if (selectedProviders.includes(provider)) {
if (effectiveSelection.includes(provider)) { // Deactivate: remove from selection
// Deactivate: remove from effective selection onChange(selectedProviders.filter((p) => p !== provider));
onChange(effectiveSelection.filter((p) => p !== provider));
} else { } else {
// Activate: add to effective selection // Activate: add to selection
onChange([...effectiveSelection, provider]); onChange([...selectedProviders, provider]);
} }
}; };
@ -165,14 +163,11 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
// Summary icon for button // Summary icon for button
const summaryIcon = useMemo(() => { const summaryIcon = useMemo(() => {
if (selectedProviders.length === 0 || selectedProviders.length === allowedProviders.length) {
return '🤖';
}
if (selectedProviders.length === 1) { if (selectedProviders.length === 1) {
return PROVIDER_ICONS[selectedProviders[0]] || '🔌'; return PROVIDER_ICONS[selectedProviders[0]] || '🔌';
} }
return '🤖'; return '🤖';
}, [selectedProviders, allowedProviders]); }, [selectedProviders]);
return ( return (
<div <div
@ -208,7 +203,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
type="button" type="button"
onClick={handleSelectNone} onClick={handleSelectNone}
disabled={disabled} disabled={disabled}
className={styles.actionButton} className={`${styles.actionButton} ${isNoneSelected ? styles.active : ''}`}
> >
Keine Keine
</button> </button>
@ -225,7 +220,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
> >
<input <input
type="checkbox" type="checkbox"
checked={effectiveSelection.includes(provider)} checked={selectedProviders.includes(provider)}
onChange={() => handleToggle(provider)} onChange={() => handleToggle(provider)}
disabled={disabled} disabled={disabled}
/> />

View file

@ -268,7 +268,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
const balanceInfo = account ? ` (${formatCurrency(account.balance)})` : ' (kein Konto)'; const balanceInfo = account ? ` (${formatCurrency(account.balance)})` : ' (kein Konto)';
return ( return (
<option key={user.id} value={user.id}> <option key={user.id} value={user.id}>
{user.displayName || user.email || user.id}{balanceInfo} {user.displayName || user.username || user.id}{balanceInfo}
</option> </option>
); );
})} })}
@ -339,7 +339,7 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
for (const user of users) { for (const user of users) {
const displayName = user.displayName const displayName = user.displayName
|| [user.firstName, user.lastName].filter(Boolean).join(' ') || [user.firstName, user.lastName].filter(Boolean).join(' ')
|| user.email || user.username
|| user.id; || user.id;
map.set(user.id, displayName); map.set(user.id, displayName);
} }

View file

@ -114,6 +114,28 @@ const StatisticsChart: React.FC<StatisticsChartProps> = ({ statistics, loading }
)} )}
</div> </div>
<div className={styles.chartSection}>
<h4>Kosten nach Modell</h4>
{Object.entries(statistics.costByModel || {}).length === 0 ? (
<div className={styles.noData}>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}> <div className={styles.chartSection}>
<h4>Kosten nach Feature</h4> <h4>Kosten nach Feature</h4>
{Object.entries(statistics.costByFeature).length === 0 ? ( {Object.entries(statistics.costByFeature).length === 0 ? (

View file

@ -35,6 +35,7 @@ interface ViewStatistics {
totalCost: number; totalCost: number;
transactionCount: number; transactionCount: number;
costByProvider: Record<string, number>; costByProvider: Record<string, number>;
costByModel: Record<string, number>;
costByFeature: Record<string, number>; costByFeature: Record<string, number>;
costByMandate: Record<string, number>; costByMandate: Record<string, number>;
timeSeries: Array<{ date: string; cost: number; count: number }>; timeSeries: Array<{ date: string; cost: number; count: number }>;
@ -134,6 +135,7 @@ function _recordToChartData(record: Record<string, number>): ReportChartDataPoin
function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] { function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] {
const topProvider = Object.entries(viewStats.costByProvider).sort((a, b) => b[1] - a[1])[0]; const topProvider = Object.entries(viewStats.costByProvider).sort((a, b) => b[1] - a[1])[0];
const topModel = Object.entries(viewStats.costByModel || {}).sort((a, b) => b[1] - a[1])[0];
const topFeature = Object.entries(viewStats.costByFeature).sort((a, b) => b[1] - a[1])[0]; const topFeature = Object.entries(viewStats.costByFeature).sort((a, b) => b[1] - a[1])[0];
return [ return [
@ -150,15 +152,15 @@ function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] {
value: Object.keys(viewStats.costByProvider).length, value: Object.keys(viewStats.costByProvider).length,
subtitle: topProvider ? `Top: ${topProvider[0]}` : 'Keine Nutzung' subtitle: topProvider ? `Top: ${topProvider[0]}` : 'Keine Nutzung'
}, },
{
label: 'Modelle',
value: Object.keys(viewStats.costByModel || {}).length,
subtitle: topModel ? `Top: ${topModel[0]}` : 'Keine Nutzung'
},
{ {
label: 'Features', label: 'Features',
value: Object.keys(viewStats.costByFeature).length, value: Object.keys(viewStats.costByFeature).length,
subtitle: topFeature ? `Top: ${topFeature[0]}` : 'Keine Nutzung' subtitle: topFeature ? `Top: ${topFeature[0]}` : 'Keine Nutzung'
},
{
label: 'Mandanten',
value: Object.keys(viewStats.costByMandate).length,
subtitle: 'aktiv genutzt'
} }
] ]
}, },
@ -169,6 +171,13 @@ function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] {
formatValue: _formatCurrency, formatValue: _formatCurrency,
span: 'half' as const span: 'half' as const
}, },
{
type: 'horizontalBar',
title: 'Kosten nach Modell',
data: _recordToChartData(viewStats.costByModel || {}),
formatValue: _formatCurrency,
span: 'half' as const
},
{ {
type: 'horizontalBar', type: 'horizontalBar',
title: 'Kosten nach Feature', title: 'Kosten nach Feature',
@ -206,6 +215,14 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
donut: true, donut: true,
span: 'half' as const span: 'half' as const
}, },
{
type: 'pieChart',
title: 'Verteilung nach Modell',
data: _recordToChartData(viewStats.costByModel || {}),
formatValue: _formatCurrency,
donut: true,
span: 'half' as const
},
{ {
type: 'pieChart', type: 'pieChart',
title: 'Verteilung nach Feature', title: 'Verteilung nach Feature',
@ -234,6 +251,7 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
{ metric: 'Transaktionen', value: String(viewStats.transactionCount) }, { metric: 'Transaktionen', value: String(viewStats.transactionCount) },
{ metric: 'Durchschnitt / Transaktion', value: _formatCurrency(avgCost) }, { metric: 'Durchschnitt / Transaktion', value: _formatCurrency(avgCost) },
{ metric: 'Anbieter', value: String(Object.keys(viewStats.costByProvider).length) }, { metric: 'Anbieter', value: String(Object.keys(viewStats.costByProvider).length) },
{ metric: 'Modelle', value: String(Object.keys(viewStats.costByModel || {}).length) },
{ metric: 'Features', value: String(Object.keys(viewStats.costByFeature).length) }, { metric: 'Features', value: String(Object.keys(viewStats.costByFeature).length) },
{ metric: 'Mandanten', value: String(Object.keys(viewStats.costByMandate).length) } { metric: 'Mandanten', value: String(Object.keys(viewStats.costByMandate).length) }
] ]
@ -304,8 +322,10 @@ export const BillingDataView: React.FC = () => {
setTransactionsError(null); setTransactionsError(null);
const params: any = {}; const params: any = {};
if (paginationParams) { // Only serialize if it's a plain pagination object (not a React event or other non-serializable object)
params.pagination = JSON.stringify(paginationParams); if (paginationParams && typeof paginationParams === 'object' && 'page' in paginationParams) {
const { page, pageSize, sortBy, sortDirection, search, filters } = paginationParams;
params.pagination = JSON.stringify({ page, pageSize, sortBy, sortDirection, search, filters });
} }
const response = await api.get('/api/billing/view/users/transactions', { params }); const response = await api.get('/api/billing/view/users/transactions', { params });
@ -353,6 +373,7 @@ export const BillingDataView: React.FC = () => {
{ key: 'transactionType', label: 'Typ', type: 'text' as any, sortable: true, filterable: true, width: 100 }, { key: 'transactionType', label: 'Typ', type: 'text' as any, sortable: true, filterable: true, width: 100 },
{ key: 'description', label: 'Beschreibung', type: 'text' as any, searchable: true, width: 250 }, { key: 'description', label: 'Beschreibung', type: 'text' as any, searchable: true, width: 250 },
{ key: 'aicoreProvider', label: 'Anbieter', type: 'text' as any, sortable: true, filterable: true, width: 120 }, { key: 'aicoreProvider', label: 'Anbieter', type: 'text' as any, sortable: true, filterable: true, width: 120 },
{ key: 'aicoreModel', label: 'Modell', type: 'text' as any, sortable: true, filterable: true, width: 150 },
{ key: 'featureCode', label: 'Feature', type: 'text' as any, sortable: true, filterable: true, width: 120 }, { key: 'featureCode', label: 'Feature', type: 'text' as any, sortable: true, filterable: true, width: 120 },
{ key: 'amount', label: 'Betrag (CHF)', type: 'number' as any, sortable: true, width: 120 }, { key: 'amount', label: 'Betrag (CHF)', type: 'number' as any, sortable: true, width: 120 },
], []); ], []);

View file

@ -146,6 +146,7 @@ const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) =>
<th>Typ</th> <th>Typ</th>
<th>Beschreibung</th> <th>Beschreibung</th>
<th>Anbieter</th> <th>Anbieter</th>
<th>Modell</th>
<th>Feature</th> <th>Feature</th>
<th style={{ textAlign: 'right' }}>Betrag</th> <th style={{ textAlign: 'right' }}>Betrag</th>
</tr> </tr>
@ -162,6 +163,7 @@ const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) =>
</td> </td>
<td>{t.description}</td> <td>{t.description}</td>
<td>{t.aicoreProvider || '-'}</td> <td>{t.aicoreProvider || '-'}</td>
<td>{t.aicoreModel || '-'}</td>
<td>{t.featureCode || '-'}</td> <td>{t.featureCode || '-'}</td>
<td style={{ textAlign: 'right' }}> <td style={{ textAlign: 'right' }}>
{t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)} {t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)}

View file

@ -65,6 +65,7 @@ const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
</td> </td>
<td>{transaction.description}</td> <td>{transaction.description}</td>
<td>{transaction.aicoreProvider || '-'}</td> <td>{transaction.aicoreProvider || '-'}</td>
<td>{transaction.aicoreModel || '-'}</td>
<td>{transaction.featureCode || '-'}</td> <td>{transaction.featureCode || '-'}</td>
<td style={{ textAlign: 'right' }}> <td style={{ textAlign: 'right' }}>
{transaction.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(transaction.amount)} {transaction.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(transaction.amount)}
@ -114,6 +115,7 @@ export const BillingTransactions: React.FC = () => {
<th>Typ</th> <th>Typ</th>
<th>Beschreibung</th> <th>Beschreibung</th>
<th>Anbieter</th> <th>Anbieter</th>
<th>Modell</th>
<th>Feature</th> <th>Feature</th>
<th style={{ textAlign: 'right' }}>Betrag</th> <th style={{ textAlign: 'right' }}>Betrag</th>
</tr> </tr>

View file

@ -228,6 +228,7 @@ const UserTransactionTable: React.FC<UserTransactionTableProps> = ({
<th>Typ</th> <th>Typ</th>
<th>Beschreibung</th> <th>Beschreibung</th>
<th>Anbieter</th> <th>Anbieter</th>
<th>Modell</th>
<th>Feature</th> <th>Feature</th>
<th style={{ textAlign: 'right' }}>Betrag</th> <th style={{ textAlign: 'right' }}>Betrag</th>
</tr> </tr>
@ -245,6 +246,7 @@ const UserTransactionTable: React.FC<UserTransactionTableProps> = ({
</td> </td>
<td>{t.description}</td> <td>{t.description}</td>
<td>{t.aicoreProvider || '-'}</td> <td>{t.aicoreProvider || '-'}</td>
<td>{t.aicoreModel || '-'}</td>
<td>{t.featureCode || '-'}</td> <td>{t.featureCode || '-'}</td>
<td style={{ textAlign: 'right' }}> <td style={{ textAlign: 'right' }}>
{t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)} {t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)}