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

View file

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

View file

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

View file

@ -35,6 +35,7 @@ interface ViewStatistics {
totalCost: number;
transactionCount: number;
costByProvider: Record<string, number>;
costByModel: Record<string, number>;
costByFeature: Record<string, number>;
costByMandate: Record<string, 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[] {
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];
return [
@ -150,15 +152,15 @@ function _buildOverviewSections(viewStats: ViewStatistics): ReportSection[] {
value: Object.keys(viewStats.costByProvider).length,
subtitle: topProvider ? `Top: ${topProvider[0]}` : 'Keine Nutzung'
},
{
label: 'Modelle',
value: Object.keys(viewStats.costByModel || {}).length,
subtitle: topModel ? `Top: ${topModel[0]}` : 'Keine Nutzung'
},
{
label: 'Features',
value: Object.keys(viewStats.costByFeature).length,
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,
span: 'half' as const
},
{
type: 'horizontalBar',
title: 'Kosten nach Modell',
data: _recordToChartData(viewStats.costByModel || {}),
formatValue: _formatCurrency,
span: 'half' as const
},
{
type: 'horizontalBar',
title: 'Kosten nach Feature',
@ -206,6 +215,14 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
donut: true,
span: 'half' as const
},
{
type: 'pieChart',
title: 'Verteilung nach Modell',
data: _recordToChartData(viewStats.costByModel || {}),
formatValue: _formatCurrency,
donut: true,
span: 'half' as const
},
{
type: 'pieChart',
title: 'Verteilung nach Feature',
@ -234,6 +251,7 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
{ metric: 'Transaktionen', value: String(viewStats.transactionCount) },
{ metric: 'Durchschnitt / Transaktion', value: _formatCurrency(avgCost) },
{ 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: 'Mandanten', value: String(Object.keys(viewStats.costByMandate).length) }
]
@ -304,8 +322,10 @@ export const BillingDataView: React.FC = () => {
setTransactionsError(null);
const params: any = {};
if (paginationParams) {
params.pagination = JSON.stringify(paginationParams);
// Only serialize if it's a plain pagination object (not a React event or other non-serializable object)
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 });
@ -353,6 +373,7 @@ export const BillingDataView: React.FC = () => {
{ 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: '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: '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>Beschreibung</th>
<th>Anbieter</th>
<th>Modell</th>
<th>Feature</th>
<th style={{ textAlign: 'right' }}>Betrag</th>
</tr>
@ -162,6 +163,7 @@ const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) =>
</td>
<td>{t.description}</td>
<td>{t.aicoreProvider || '-'}</td>
<td>{t.aicoreModel || '-'}</td>
<td>{t.featureCode || '-'}</td>
<td style={{ textAlign: 'right' }}>
{t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)}

View file

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

View file

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