billing rbac
This commit is contained in:
parent
8f29bdb270
commit
15c93b3bf0
8 changed files with 77 additions and 26 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
], []);
|
], []);
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue