billing fixes

This commit is contained in:
ValueOn AG 2026-02-06 16:18:44 +01:00
parent 46e58bf019
commit a544ab2c78
10 changed files with 482 additions and 257 deletions

View file

@ -252,3 +252,28 @@ export async function fetchTransactionsAdmin(
params: { limit }
});
}
/**
* User summary for billing admin
*/
export interface MandateUserSummary {
id: string;
email?: string;
firstName?: string;
lastName?: string;
displayName?: string;
}
/**
* Fetch all users for a mandate (Admin)
* Endpoint: GET /api/billing/admin/users/{mandateId}
*/
export async function fetchUsersForMandateAdmin(
request: ApiRequestFunction,
mandateId: string
): Promise<MandateUserSummary[]> {
return await request({
url: `/api/billing/admin/users/${mandateId}`,
method: 'get'
});
}

View file

@ -34,6 +34,11 @@ export const UserSection: React.FC = () => {
setShowMenu(false);
};
const handleBilling = () => {
navigate('/billing');
setShowMenu(false);
};
const handleLegal = () => {
setShowLegalModal(true);
setShowMenu(false);
@ -72,6 +77,14 @@ export const UserSection: React.FC = () => {
{showMenu && (
<div className={styles.menu}>
<button
className={styles.menuItem}
onClick={handleBilling}
>
<span className={styles.menuIcon}>💰</span>
Guthaben
</button>
<button
className={styles.menuItem}
onClick={handleSettings}

View file

@ -44,7 +44,110 @@
.providerMultiSelect {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 8px);
gap: var(--spacing-xs, 4px);
position: relative;
}
.providerMultiSelect.collapsed {
/* Collapsed state styles */
}
.providerMultiSelect.expanded {
/* Expanded state styles */
}
/* Collapsible Header Button */
.collapseHeader {
display: flex;
align-items: center;
gap: var(--spacing-xs, 4px);
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md, 6px);
background: var(--color-bg-input);
color: var(--color-text-primary);
font-size: var(--font-size-sm, 0.875rem);
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
}
.collapseHeader:hover:not(:disabled) {
border-color: var(--color-primary);
background: var(--color-bg-hover);
}
.collapseHeader:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.summaryIcons {
font-size: 0.9em;
}
.summaryText {
flex: 1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.expandIcon {
font-size: 0.7em;
color: var(--color-text-secondary);
}
/* Expandable Content - opens upward */
.expandableContent {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
z-index: 100;
margin-bottom: var(--spacing-xs, 4px);
padding: var(--spacing-sm, 8px);
background: #2d2d2d;
color: #e0e0e0;
border: 1px solid #444;
border-radius: var(--border-radius-md, 6px);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.4);
min-width: 200px;
}
.expandableContent .label {
color: #b0b0b0;
}
.expandableContent .checkboxList {
background: #252525;
}
.expandableContent .checkboxItem {
color: #e0e0e0;
}
.expandableContent .checkboxItem:hover {
background: #3a3a3a;
}
.expandableContent .providerName {
color: #e0e0e0;
}
.expandableContent .hint {
color: #888;
}
.expandableContent .actionButton {
background: #3a3a3a;
color: #e0e0e0;
border-color: #555;
}
.expandableContent .actionButton:hover:not(:disabled) {
background: #4a4a4a;
}
.selectActions {

View file

@ -10,7 +10,7 @@
* - Lädt verfügbare Provider aus dem Billing-System
*/
import React, { useEffect, useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useBilling } from '../../hooks/useBilling';
import styles from './ProviderSelector.module.css';
@ -99,6 +99,7 @@ interface ProviderMultiSelectProps {
className?: string;
label?: string;
showLabel?: boolean;
defaultExpanded?: boolean;
}
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
@ -106,9 +107,11 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
onChange,
disabled = false,
className,
label = 'Erlaubte AI-Provider',
label = 'AI-Provider',
showLabel = true,
defaultExpanded = false,
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => {
@ -133,8 +136,42 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
onChange([]);
};
// Summary text for collapsed view
const summaryText = useMemo(() => {
if (selectedProviders.length === 0) {
return 'Alle Provider';
}
if (selectedProviders.length === 1) {
return PROVIDER_LABELS[selectedProviders[0]] || selectedProviders[0];
}
return `${selectedProviders.length} Provider`;
}, [selectedProviders]);
// Summary icons for collapsed view
const summaryIcons = useMemo(() => {
if (selectedProviders.length === 0) {
return '🤖';
}
return selectedProviders.slice(0, 3).map(p => PROVIDER_ICONS[p] || '🔌').join('');
}, [selectedProviders]);
return (
<div className={`${styles.providerMultiSelect} ${className || ''}`}>
<div className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}>
{/* Collapsible Header */}
<button
type="button"
className={styles.collapseHeader}
onClick={() => setIsExpanded(!isExpanded)}
disabled={disabled}
>
<span className={styles.summaryIcons}>{summaryIcons}</span>
<span className={styles.summaryText}>{summaryText}</span>
<span className={styles.expandIcon}>{isExpanded ? '▲' : '▼'}</span>
</button>
{/* Expandable Content */}
{isExpanded && (
<div className={styles.expandableContent}>
{showLabel && <label className={styles.label}>{label}</label>}
<div className={styles.selectActions}>
@ -186,6 +223,8 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
</div>
)}
</div>
)}
</div>
);
};

View file

@ -30,7 +30,7 @@ export function useDashboardInputForm(instanceId: string) {
const [optimisticMessage, setOptimisticMessage] = useState<WorkflowMessage | null>(null);
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
const [workflowMode, setWorkflowMode] = useState<'Dynamic' | 'Automation' | null>(null);
const [selectedProvider, setSelectedProvider] = useState<string>(''); // AI provider selection
const [selectedProviders, setSelectedProviders] = useState<string[]>([]); // AI provider selection (multiselect)
const { checkPermission, canView } = usePermissions();
const [playgroundUIPermission, setPlaygroundUIPermission] = useState<boolean>(false);
@ -596,7 +596,7 @@ export function useDashboardInputForm(instanceId: string) {
prompt: trimmedInput,
listFileId: fileIdsToSend.length > 0 ? fileIdsToSend : undefined,
userLanguage: 'en',
preferredProvider: selectedProvider || undefined // AI provider selection
preferredProviders: selectedProviders.length > 0 ? selectedProviders : undefined // AI provider selection (multiselect)
};
const result = await startWorkflow(requestBody, workflowOptions);
@ -638,7 +638,7 @@ export function useDashboardInputForm(instanceId: string) {
setWorkflowStatusOptimistic('idle');
}
}
}, [inputValue, pendingFiles, isRunning, workflowId, startingWorkflow, startWorkflow, stopWorkflow, resetWorkflow, refetchWorkflows, selectWorkflowFromContext, selectWorkflow, chatWorkflowPermission, workflowMode, selectedProvider, setWorkflowStatusOptimistic]);
}, [inputValue, pendingFiles, isRunning, workflowId, startingWorkflow, startWorkflow, stopWorkflow, resetWorkflow, refetchWorkflows, selectWorkflowFromContext, selectWorkflow, chatWorkflowPermission, workflowMode, selectedProviders, setWorkflowStatusOptimistic]);
useEffect(() => {
const handleWorkflowCleared = () => {
@ -823,9 +823,9 @@ export function useDashboardInputForm(instanceId: string) {
handleFileAttach,
handleFileUploadAndAttach,
latestStats,
// AI Provider selection
selectedProvider,
onProviderSelect: setSelectedProvider
// AI Provider selection (multiselect)
selectedProviders,
onProvidersChange: setSelectedProviders
};
}

View file

@ -479,25 +479,36 @@ export function useWorkflowLifecycle(instanceId: string) {
// Continue polling if:
// 1. Workflow is currently running, OR
// 2. Workflow just completed (within last 10 seconds) - grace period to catch final messages
// 2. Workflow just completed (within last 5 seconds) - grace period to catch final messages
// Stop polling for failed or stopped workflows immediately
// Use ref for statusChangedFromRunningAt to get latest value (state updates are async)
const changedAtRef = statusChangedFromRunningAtRef.current;
const shouldPoll = workflowStatus === 'running' ||
(workflowStatus === 'completed' && changedAtRef !== null && Date.now() - changedAtRef < 10000);
const gracePeriodMs = 5000; // 5 seconds grace period
const timeSinceCompletion = changedAtRef !== null ? Date.now() - changedAtRef : Infinity;
const isInGracePeriod = workflowStatus === 'completed' && changedAtRef !== null && timeSinceCompletion < gracePeriodMs;
const shouldPoll = workflowStatus === 'running' || isInGracePeriod;
if (shouldPoll) {
// Reset lastRenderedTimestamp for first poll (fetch all historical data)
if (lastRenderedTimestampRef.current === null) {
lastRenderedTimestampRef.current = null; // null means fetch all
}
// Start polling
pollingControllerRef.current.startPolling(workflowId, pollWorkflowData);
// If in grace period, set a timer to stop polling after grace period expires
if (isInGracePeriod) {
const remainingGraceTime = gracePeriodMs - timeSinceCompletion;
const graceTimer = setTimeout(() => {
pollingControllerRef.current.stopPolling();
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
}, remainingGraceTime + 100); // Small buffer
return () => {
clearTimeout(graceTimer);
pollingControllerRef.current.stopPolling();
};
}
} else {
// Stop polling for failed, stopped, or completed (after grace period) workflows
pollingControllerRef.current.stopPolling();
// Clear the status change timestamp when we stop polling (only if not already null)
// Clear the status change timestamp when we stop polling
if (statusChangedFromRunningAt !== null) {
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
@ -507,7 +518,7 @@ export function useWorkflowLifecycle(instanceId: string) {
return () => {
pollingControllerRef.current.stopPolling();
};
}, [workflowStatus, workflowId, pollWorkflowData]);
}, [workflowStatus, workflowId, pollWorkflowData, statusChangedFromRunningAt]);
const handleStartWorkflow = useCallback(async (
workflowData: StartWorkflowRequest,

View file

@ -18,6 +18,7 @@ import {
addCreditAdmin,
fetchAccountsAdmin,
fetchTransactionsAdmin,
fetchUsersForMandateAdmin,
type BillingBalance,
type BillingTransaction,
type BillingSettings,
@ -25,6 +26,7 @@ import {
type UsageReport,
type AccountSummary,
type CreditAddRequest,
type MandateUserSummary,
} from '../api/billingApi';
// Re-export types
@ -36,6 +38,7 @@ export type {
UsageReport,
AccountSummary,
CreditAddRequest,
MandateUserSummary,
};
export type { BillingModel, TransactionType, ReferenceType } from '../api/billingApi';
@ -145,6 +148,7 @@ export function useBillingAdmin(mandateId?: string) {
const [settings, setSettings] = useState<BillingSettings | null>(null);
const [accounts, setAccounts] = useState<AccountSummary[]>([]);
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
const [users, setUsers] = useState<MandateUserSummary[]>([]);
const { request, isLoading: loading, error } = useApiRequest();
// Fetch settings for a mandate
@ -232,12 +236,29 @@ export function useBillingAdmin(mandateId?: string) {
}
}, [request, mandateId]);
// Fetch users for a mandate
const loadUsers = useCallback(async (targetMandateId?: string) => {
const mId = targetMandateId || mandateId;
if (!mId) return [];
try {
const data = await fetchUsersForMandateAdmin(request, mId);
setUsers(Array.isArray(data) ? data : []);
return data;
} catch (err) {
console.error('Error loading users:', err);
setUsers([]);
return [];
}
}, [request, mandateId]);
// Load data when mandateId changes
useEffect(() => {
if (mandateId) {
loadSettings();
loadAccounts();
loadTransactions();
loadUsers();
}
}, [mandateId]);
@ -245,6 +266,7 @@ export function useBillingAdmin(mandateId?: string) {
settings,
accounts,
transactions,
users,
loading,
error,
loadSettings,
@ -252,11 +274,13 @@ export function useBillingAdmin(mandateId?: string) {
addCredit,
loadAccounts,
loadTransactions,
loadUsers,
refetch: () => {
if (mandateId) {
loadSettings();
loadAccounts();
loadTransactions();
loadUsers();
}
},
};

View file

@ -5,25 +5,26 @@
============================================================================ */
.billingDashboard {
padding: var(--spacing-lg);
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
min-height: 100%;
}
.pageHeader {
margin-bottom: var(--spacing-xl);
margin-bottom: 2rem;
}
.pageHeader h1 {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
margin: 0 0 var(--spacing-xs) 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin: 0 0 0.25rem 0;
}
.subtitle {
font-size: var(--font-size-base);
color: var(--color-text-secondary);
font-size: 0.875rem;
color: var(--text-secondary, #888);
margin: 0;
}
@ -32,23 +33,23 @@
============================================================================ */
.section {
margin-bottom: var(--spacing-xl);
margin-bottom: 2rem;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
margin-bottom: 1rem;
flex-wrap: wrap;
gap: var(--spacing-sm);
gap: 0.5rem;
}
.sectionTitle {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin: 0 0 var(--spacing-md) 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin: 0 0 1rem 0;
}
.sectionHeader .sectionTitle {
@ -62,65 +63,65 @@
.balanceGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-md);
gap: 1rem;
}
.balanceCard {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-lg);
background: var(--surface-color, #1e1e1e);
border: 1px solid var(--border-color, #333);
border-radius: 12px;
padding: 1.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.balanceCard:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
border-color: var(--primary-color, #f25843);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.balanceCard.warning {
border-color: var(--color-warning);
background: var(--color-warning-bg, rgba(255, 193, 7, 0.1));
border-color: #ffc107;
background: rgba(255, 193, 7, 0.1);
}
.balanceHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-md);
margin-bottom: 1rem;
}
.mandateName {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin: 0;
}
.billingModel {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
font-size: 0.75rem;
color: var(--text-secondary, #888);
background: var(--bg-secondary, #2a2a2a);
padding: 2px 8px;
border-radius: var(--border-radius-sm);
border-radius: 4px;
}
.balanceAmount {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
margin-bottom: var(--spacing-sm);
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #e0e0e0);
margin-bottom: 0.5rem;
}
.warningBadge {
display: inline-block;
font-size: var(--font-size-xs);
color: var(--color-warning-text, #856404);
background: var(--color-warning-badge-bg, rgba(255, 193, 7, 0.3));
font-size: 0.75rem;
color: #856404;
background: rgba(255, 193, 7, 0.3);
padding: 4px 8px;
border-radius: var(--border-radius-sm);
font-weight: var(--font-weight-medium);
border-radius: 4px;
font-weight: 500;
}
/* ============================================================================
@ -129,56 +130,56 @@
.periodSelector {
display: flex;
gap: var(--spacing-sm);
gap: 0.5rem;
align-items: center;
}
.select {
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
background: var(--color-bg-input);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
background: var(--surface-color, #1e1e1e);
color: var(--text-primary, #e0e0e0);
font-size: 0.875rem;
cursor: pointer;
}
.select:focus {
outline: none;
border-color: var(--color-primary);
border-color: var(--primary-color, #f25843);
}
.statisticsChart {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-lg);
background: var(--surface-color, #1e1e1e);
border: 1px solid var(--border-color, #333);
border-radius: 12px;
padding: 1.5rem;
}
.totalCost {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--spacing-lg);
background: var(--color-bg-secondary);
border-radius: var(--border-radius-md);
margin-bottom: var(--spacing-lg);
padding: 1.5rem;
background: var(--bg-secondary, #2a2a2a);
border-radius: 8px;
margin-bottom: 1.5rem;
}
.totalLabel {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin-bottom: var(--spacing-xs);
font-size: 0.875rem;
color: var(--text-secondary, #888);
margin-bottom: 0.25rem;
}
.totalAmount {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
font-size: 2rem;
font-weight: 700;
color: var(--text-primary, #e0e0e0);
}
.chartSection {
margin-bottom: var(--spacing-lg);
margin-bottom: 1.5rem;
}
.chartSection:last-child {
@ -186,10 +187,10 @@
}
.chartSection h4 {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
margin: 0 0 var(--spacing-md) 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #888);
margin: 0 0 1rem 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
@ -197,34 +198,34 @@
.barChart {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
gap: 0.5rem;
}
.barRow {
display: flex;
align-items: center;
gap: var(--spacing-sm);
gap: 0.5rem;
}
.barLabel {
width: 100px;
font-size: var(--font-size-sm);
color: var(--color-text-primary);
font-size: 0.875rem;
color: var(--text-primary, #e0e0e0);
text-transform: capitalize;
}
.barContainer {
flex: 1;
height: 24px;
background: var(--color-bg-secondary);
border-radius: var(--border-radius-sm);
background: var(--bg-secondary, #2a2a2a);
border-radius: 4px;
overflow: hidden;
}
.bar {
height: 100%;
background: var(--color-primary);
border-radius: var(--border-radius-sm);
background: var(--primary-color, #f25843);
border-radius: 4px;
transition: width 0.3s ease;
min-width: 4px;
}
@ -232,36 +233,36 @@
.barValue {
width: 100px;
text-align: right;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
font-family: var(--font-mono);
font-size: 0.875rem;
color: var(--text-secondary, #888);
font-family: monospace;
}
.featureList {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
gap: 0.25rem;
}
.featureRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm);
background: var(--color-bg-secondary);
border-radius: var(--border-radius-sm);
padding: 0.5rem;
background: var(--bg-secondary, #2a2a2a);
border-radius: 4px;
}
.featureLabel {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
font-size: 0.875rem;
color: var(--text-primary, #e0e0e0);
text-transform: capitalize;
}
.featureValue {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
font-family: var(--font-mono);
font-size: 0.875rem;
color: var(--text-secondary, #888);
font-family: monospace;
}
/* ============================================================================
@ -275,46 +276,46 @@
.transactionsTable th,
.transactionsTable td {
padding: var(--spacing-sm) var(--spacing-md);
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--border-color, #333);
}
.transactionsTable th {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #888);
text-transform: uppercase;
letter-spacing: 0.5px;
background: var(--color-bg-secondary);
background: var(--bg-secondary, #2a2a2a);
}
.transactionsTable td {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
font-size: 0.875rem;
color: var(--text-primary, #e0e0e0);
}
.transactionType {
display: inline-block;
padding: 2px 8px;
border-radius: var(--border-radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.transactionType.credit {
background: var(--color-success-bg, rgba(40, 167, 69, 0.1));
color: var(--color-success, #28a745);
background: rgba(40, 167, 69, 0.1);
color: #28a745;
}
.transactionType.debit {
background: var(--color-error-bg, rgba(220, 53, 69, 0.1));
color: var(--color-error, #dc3545);
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
}
.transactionType.adjustment {
background: var(--color-info-bg, rgba(23, 162, 184, 0.1));
color: var(--color-info, #17a2b8);
background: rgba(23, 162, 184, 0.1);
color: #17a2b8;
}
/* ============================================================================
@ -322,86 +323,86 @@
============================================================================ */
.adminSection {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
background: var(--surface-color, #1e1e1e);
border: 1px solid var(--border-color, #333);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.adminSection h3 {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin: 0 0 var(--spacing-md) 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin: 0 0 1rem 0;
}
.formRow {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
gap: 1rem;
margin-bottom: 1rem;
}
.formGroup {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
gap: 0.25rem;
}
.formGroup label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #888);
}
.input {
padding: var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
background: var(--color-bg-input);
color: var(--color-text-primary);
font-size: var(--font-size-base);
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
background: var(--surface-color, #1e1e1e);
color: var(--text-primary, #e0e0e0);
font-size: 0.875rem;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
border-color: var(--primary-color, #f25843);
}
.accountsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--spacing-md);
gap: 1rem;
}
.accountCard {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
padding: var(--spacing-md);
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
padding: 1rem;
}
.accountCard h4 {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin: 0 0 var(--spacing-sm) 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
margin: 0 0 0.5rem 0;
}
.accountInfo {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
gap: 0.25rem;
font-size: 0.875rem;
}
.accountInfo span {
color: var(--color-text-secondary);
color: var(--text-secondary, #888);
}
.accountInfo strong {
color: var(--color-text-primary);
color: var(--text-primary, #e0e0e0);
}
/* ============================================================================
@ -409,22 +410,22 @@
============================================================================ */
.button {
padding: var(--spacing-sm) var(--spacing-md);
padding: 0.5rem 1rem;
border: none;
border-radius: var(--border-radius-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.buttonPrimary {
background: var(--color-primary);
background: var(--primary-color, #f25843);
color: white;
}
.buttonPrimary:hover {
background: var(--color-primary-dark);
background: var(--primary-dark, #d94d3a);
}
.buttonPrimary:disabled {
@ -433,13 +434,13 @@
}
.buttonSecondary {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
background: var(--bg-secondary, #2a2a2a);
color: var(--text-primary, #e0e0e0);
border: 1px solid var(--border-color, #333);
}
.buttonSecondary:hover {
background: var(--color-bg-hover);
background: var(--surface-color, #1e1e1e);
}
/* ============================================================================
@ -450,37 +451,37 @@
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
padding: 2rem;
color: var(--text-secondary, #888);
font-size: 0.875rem;
}
.noData {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-lg);
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
padding: 1.5rem;
color: var(--text-tertiary, #666);
font-size: 0.875rem;
font-style: italic;
}
.errorMessage {
background: var(--color-error-bg, rgba(220, 53, 69, 0.1));
color: var(--color-error, #dc3545);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius-md);
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-md);
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.successMessage {
background: var(--color-success-bg, rgba(40, 167, 69, 0.1));
color: var(--color-success, #28a745);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius-md);
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-md);
background: rgba(40, 167, 69, 0.1);
color: #28a745;
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
margin-bottom: 1rem;
}
/* ============================================================================
@ -489,7 +490,7 @@
@media (max-width: 768px) {
.billingDashboard {
padding: var(--spacing-md);
padding: 1rem;
}
.balanceGrid {
@ -513,6 +514,6 @@
.barLabel,
.barValue {
width: 80px;
font-size: var(--font-size-xs);
font-size: 0.75rem;
}
}

View file

@ -8,7 +8,7 @@
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useBillingAdmin, type BillingSettings, type AccountSummary } from '../../hooks/useBilling';
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
import { useAdminMandates } from '../../hooks/useMandates';
import styles from './Billing.module.css';
@ -191,10 +191,11 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
interface CreditAdderProps {
settings: BillingSettings | null;
accounts: AccountSummary[];
users: MandateUserSummary[];
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<void>;
}
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, onAddCredit }) => {
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [amount, setAmount] = useState<number>(10);
const [description, setDescription] = useState<string>('Manuelles Aufladen');
@ -203,6 +204,14 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, onAddCred
const isPrepayUser = settings?.billingModel === 'PREPAY_USER';
// Map accounts by userId for balance lookup
const accountsByUserId = accounts
.filter(acc => acc.accountType === 'USER')
.reduce((map, acc) => {
if (acc.userId) map[acc.userId] = acc;
return map;
}, {} as Record<string, AccountSummary>);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (amount <= 0) {
@ -246,21 +255,23 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, onAddCred
{isPrepayUser && (
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Benutzer-Konto</label>
<label>Benutzer</label>
<select
className={styles.select}
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
required
>
<option value="">-- Konto wählen --</option>
{accounts
.filter(acc => acc.accountType === 'USER')
.map((acc) => (
<option key={acc.id} value={acc.userId || ''}>
{acc.userId} - {formatCurrency(acc.balance)}
<option value="">-- Benutzer wählen --</option>
{users.map((user) => {
const account = accountsByUserId[user.id];
const balanceInfo = account ? ` (${formatCurrency(account.balance)})` : ' (kein Konto)';
return (
<option key={user.id} value={user.id}>
{user.displayName || user.email || user.id}{balanceInfo}
</option>
))}
);
})}
</select>
</div>
</div>
@ -355,7 +366,7 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, loading }
export const BillingAdmin: React.FC = () => {
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const { settings, accounts, loading, loadSettings, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined);
const { settings, accounts, users, loading, loadSettings, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined);
const handleMandateSelect = (mandateId: string) => {
setSelectedMandateId(mandateId || null);
@ -397,6 +408,7 @@ export const BillingAdmin: React.FC = () => {
<CreditAdder
settings={settings}
accounts={accounts}
users={users}
onAddCredit={handleAddCredit}
/>

View file

@ -15,7 +15,7 @@ import { useCurrentInstance } from '../../hooks/useCurrentInstance';
import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useVoiceLanguage, VoiceLanguageSelect, Messages } from '../../components/UiComponents';
import { ProviderSelect } from '../../components/ProviderSelector';
import { ProviderMultiSelect } from '../../components/ProviderSelector';
import type { Message } from '../../components/UiComponents/Messages/MessagesTypes';
import api from '../../api';
import styles from './PlaygroundPage.module.css';
@ -58,8 +58,8 @@ export const PlaygroundPage: React.FC = () => {
deletingFiles,
previewingFiles,
downloadingFiles,
selectedProvider,
onProviderSelect,
selectedProviders,
onProvidersChange,
} = hookData;
const { prompts, refetch: refetchPrompts } = usePrompts();
@ -544,9 +544,6 @@ export const PlaygroundPage: React.FC = () => {
);
};
// Debug: Log permission status
console.log('🔐 PlaygroundPage permission check:', { playgroundUIPermission });
// Permission check - also show while loading
if (playgroundUIPermission === false) {
return (
@ -785,9 +782,9 @@ export const PlaygroundPage: React.FC = () => {
>
<FaPlus />
</button>
<ProviderSelect
value={selectedProvider}
onChange={onProviderSelect}
<ProviderMultiSelect
selectedProviders={selectedProviders}
onChange={onProvidersChange}
showLabel={false}
/>
<VoiceLanguageSelect