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

View file

@ -44,7 +44,110 @@
.providerMultiSelect { .providerMultiSelect {
display: flex; display: flex;
flex-direction: column; 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 { .selectActions {

View file

@ -10,7 +10,7 @@
* - Lädt verfügbare Provider aus dem Billing-System * - 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 { useBilling } from '../../hooks/useBilling';
import styles from './ProviderSelector.module.css'; import styles from './ProviderSelector.module.css';
@ -99,6 +99,7 @@ interface ProviderMultiSelectProps {
className?: string; className?: string;
label?: string; label?: string;
showLabel?: boolean; showLabel?: boolean;
defaultExpanded?: boolean;
} }
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
@ -106,9 +107,11 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
onChange, onChange,
disabled = false, disabled = false,
className, className,
label = 'Erlaubte AI-Provider', label = 'AI-Provider',
showLabel = true, showLabel = true,
defaultExpanded = false,
}) => { }) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const { allowedProviders, loadAllowedProviders, loading } = useBilling(); const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => { useEffect(() => {
@ -133,56 +136,92 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
onChange([]); 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 ( return (
<div className={`${styles.providerMultiSelect} ${className || ''}`}> <div className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}>
{showLabel && <label className={styles.label}>{label}</label>} {/* 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>
<div className={styles.selectActions}> {/* Expandable Content */}
<button {isExpanded && (
type="button" <div className={styles.expandableContent}>
onClick={handleSelectAll} {showLabel && <label className={styles.label}>{label}</label>}
disabled={disabled}
className={styles.actionButton} <div className={styles.selectActions}>
> <button
Alle type="button"
</button> onClick={handleSelectAll}
<button disabled={disabled}
type="button" className={styles.actionButton}
onClick={handleSelectNone}
disabled={disabled}
className={styles.actionButton}
>
Keine
</button>
</div>
{loading ? (
<div className={styles.loading}>Lade Provider...</div>
) : (
<div className={styles.checkboxList}>
{allowedProviders.map((provider) => (
<label
key={provider}
className={`${styles.checkboxItem} ${disabled ? styles.disabled : ''}`}
> >
<input Alle
type="checkbox" </button>
checked={selectedProviders.includes(provider)} <button
onChange={() => handleToggle(provider)} type="button"
disabled={disabled} onClick={handleSelectNone}
/> disabled={disabled}
<span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span> className={styles.actionButton}
<span className={styles.providerName}> >
{PROVIDER_LABELS[provider] || provider} Keine
</span> </button>
</label> </div>
))}
</div> {loading ? (
)} <div className={styles.loading}>Lade Provider...</div>
) : (
{selectedProviders.length === 0 && !loading && ( <div className={styles.checkboxList}>
<div className={styles.hint}> {allowedProviders.map((provider) => (
Wenn keine Provider ausgewählt sind, werden alle erlaubten Provider verwendet. <label
key={provider}
className={`${styles.checkboxItem} ${disabled ? styles.disabled : ''}`}
>
<input
type="checkbox"
checked={selectedProviders.includes(provider)}
onChange={() => handleToggle(provider)}
disabled={disabled}
/>
<span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span>
<span className={styles.providerName}>
{PROVIDER_LABELS[provider] || provider}
</span>
</label>
))}
</div>
)}
{selectedProviders.length === 0 && !loading && (
<div className={styles.hint}>
Wenn keine Provider ausgewählt sind, werden alle erlaubten Provider verwendet.
</div>
)}
</div> </div>
)} )}
</div> </div>

View file

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

View file

@ -479,25 +479,36 @@ export function useWorkflowLifecycle(instanceId: string) {
// Continue polling if: // Continue polling if:
// 1. Workflow is currently running, OR // 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 // 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 changedAtRef = statusChangedFromRunningAtRef.current;
const shouldPoll = workflowStatus === 'running' || const gracePeriodMs = 5000; // 5 seconds grace period
(workflowStatus === 'completed' && changedAtRef !== null && Date.now() - changedAtRef < 10000); const timeSinceCompletion = changedAtRef !== null ? Date.now() - changedAtRef : Infinity;
const isInGracePeriod = workflowStatus === 'completed' && changedAtRef !== null && timeSinceCompletion < gracePeriodMs;
const shouldPoll = workflowStatus === 'running' || isInGracePeriod;
if (shouldPoll) { if (shouldPoll) {
// Reset lastRenderedTimestamp for first poll (fetch all historical data)
if (lastRenderedTimestampRef.current === null) {
lastRenderedTimestampRef.current = null; // null means fetch all
}
// Start polling // Start polling
pollingControllerRef.current.startPolling(workflowId, pollWorkflowData); 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 { } else {
// Stop polling for failed, stopped, or completed (after grace period) workflows // Stop polling for failed, stopped, or completed (after grace period) workflows
pollingControllerRef.current.stopPolling(); 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) { if (statusChangedFromRunningAt !== null) {
setStatusChangedFromRunningAt(null); setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null; statusChangedFromRunningAtRef.current = null;
@ -507,7 +518,7 @@ export function useWorkflowLifecycle(instanceId: string) {
return () => { return () => {
pollingControllerRef.current.stopPolling(); pollingControllerRef.current.stopPolling();
}; };
}, [workflowStatus, workflowId, pollWorkflowData]); }, [workflowStatus, workflowId, pollWorkflowData, statusChangedFromRunningAt]);
const handleStartWorkflow = useCallback(async ( const handleStartWorkflow = useCallback(async (
workflowData: StartWorkflowRequest, workflowData: StartWorkflowRequest,

View file

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

View file

@ -5,25 +5,26 @@
============================================================================ */ ============================================================================ */
.billingDashboard { .billingDashboard {
padding: var(--spacing-lg); padding: 1.5rem;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
min-height: 100%;
} }
.pageHeader { .pageHeader {
margin-bottom: var(--spacing-xl); margin-bottom: 2rem;
} }
.pageHeader h1 { .pageHeader h1 {
font-size: var(--font-size-2xl); font-size: 1.5rem;
font-weight: var(--font-weight-bold); font-weight: 600;
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
margin: 0 0 var(--spacing-xs) 0; margin: 0 0 0.25rem 0;
} }
.subtitle { .subtitle {
font-size: var(--font-size-base); font-size: 0.875rem;
color: var(--color-text-secondary); color: var(--text-secondary, #888);
margin: 0; margin: 0;
} }
@ -32,23 +33,23 @@
============================================================================ */ ============================================================================ */
.section { .section {
margin-bottom: var(--spacing-xl); margin-bottom: 2rem;
} }
.sectionHeader { .sectionHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--spacing-md); margin-bottom: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--spacing-sm); gap: 0.5rem;
} }
.sectionTitle { .sectionTitle {
font-size: var(--font-size-lg); font-size: 1.125rem;
font-weight: var(--font-weight-semibold); font-weight: 600;
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
margin: 0 0 var(--spacing-md) 0; margin: 0 0 1rem 0;
} }
.sectionHeader .sectionTitle { .sectionHeader .sectionTitle {
@ -62,65 +63,65 @@
.balanceGrid { .balanceGrid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-md); gap: 1rem;
} }
.balanceCard { .balanceCard {
background: var(--color-bg-card); background: var(--surface-color, #1e1e1e);
border: 1px solid var(--color-border); border: 1px solid var(--border-color, #333);
border-radius: var(--border-radius-lg); border-radius: 12px;
padding: var(--spacing-lg); padding: 1.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.balanceCard:hover { .balanceCard:hover {
border-color: var(--color-primary); border-color: var(--primary-color, #f25843);
box-shadow: var(--shadow-md); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
.balanceCard.warning { .balanceCard.warning {
border-color: var(--color-warning); border-color: #ffc107;
background: var(--color-warning-bg, rgba(255, 193, 7, 0.1)); background: rgba(255, 193, 7, 0.1);
} }
.balanceHeader { .balanceHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
margin-bottom: var(--spacing-md); margin-bottom: 1rem;
} }
.mandateName { .mandateName {
font-size: var(--font-size-base); font-size: 0.875rem;
font-weight: var(--font-weight-semibold); font-weight: 600;
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
margin: 0; margin: 0;
} }
.billingModel { .billingModel {
font-size: var(--font-size-xs); font-size: 0.75rem;
color: var(--color-text-secondary); color: var(--text-secondary, #888);
background: var(--color-bg-secondary); background: var(--bg-secondary, #2a2a2a);
padding: 2px 8px; padding: 2px 8px;
border-radius: var(--border-radius-sm); border-radius: 4px;
} }
.balanceAmount { .balanceAmount {
font-size: var(--font-size-2xl); font-size: 1.5rem;
font-weight: var(--font-weight-bold); font-weight: 700;
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
margin-bottom: var(--spacing-sm); margin-bottom: 0.5rem;
} }
.warningBadge { .warningBadge {
display: inline-block; display: inline-block;
font-size: var(--font-size-xs); font-size: 0.75rem;
color: var(--color-warning-text, #856404); color: #856404;
background: var(--color-warning-badge-bg, rgba(255, 193, 7, 0.3)); background: rgba(255, 193, 7, 0.3);
padding: 4px 8px; padding: 4px 8px;
border-radius: var(--border-radius-sm); border-radius: 4px;
font-weight: var(--font-weight-medium); font-weight: 500;
} }
/* ============================================================================ /* ============================================================================
@ -129,56 +130,56 @@
.periodSelector { .periodSelector {
display: flex; display: flex;
gap: var(--spacing-sm); gap: 0.5rem;
align-items: center; align-items: center;
} }
.select { .select {
padding: var(--spacing-xs) var(--spacing-sm); padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-color, #333);
border-radius: var(--border-radius-md); border-radius: 6px;
background: var(--color-bg-input); background: var(--surface-color, #1e1e1e);
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
font-size: var(--font-size-sm); font-size: 0.875rem;
cursor: pointer; cursor: pointer;
} }
.select:focus { .select:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--primary-color, #f25843);
} }
.statisticsChart { .statisticsChart {
background: var(--color-bg-card); background: var(--surface-color, #1e1e1e);
border: 1px solid var(--color-border); border: 1px solid var(--border-color, #333);
border-radius: var(--border-radius-lg); border-radius: 12px;
padding: var(--spacing-lg); padding: 1.5rem;
} }
.totalCost { .totalCost {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: var(--spacing-lg); padding: 1.5rem;
background: var(--color-bg-secondary); background: var(--bg-secondary, #2a2a2a);
border-radius: var(--border-radius-md); border-radius: 8px;
margin-bottom: var(--spacing-lg); margin-bottom: 1.5rem;
} }
.totalLabel { .totalLabel {
font-size: var(--font-size-sm); font-size: 0.875rem;
color: var(--color-text-secondary); color: var(--text-secondary, #888);
margin-bottom: var(--spacing-xs); margin-bottom: 0.25rem;
} }
.totalAmount { .totalAmount {
font-size: var(--font-size-3xl); font-size: 2rem;
font-weight: var(--font-weight-bold); font-weight: 700;
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
} }
.chartSection { .chartSection {
margin-bottom: var(--spacing-lg); margin-bottom: 1.5rem;
} }
.chartSection:last-child { .chartSection:last-child {
@ -186,10 +187,10 @@
} }
.chartSection h4 { .chartSection h4 {
font-size: var(--font-size-sm); font-size: 0.875rem;
font-weight: var(--font-weight-semibold); font-weight: 600;
color: var(--color-text-secondary); color: var(--text-secondary, #888);
margin: 0 0 var(--spacing-md) 0; margin: 0 0 1rem 0;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@ -197,34 +198,34 @@
.barChart { .barChart {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-sm); gap: 0.5rem;
} }
.barRow { .barRow {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-sm); gap: 0.5rem;
} }
.barLabel { .barLabel {
width: 100px; width: 100px;
font-size: var(--font-size-sm); font-size: 0.875rem;
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
text-transform: capitalize; text-transform: capitalize;
} }
.barContainer { .barContainer {
flex: 1; flex: 1;
height: 24px; height: 24px;
background: var(--color-bg-secondary); background: var(--bg-secondary, #2a2a2a);
border-radius: var(--border-radius-sm); border-radius: 4px;
overflow: hidden; overflow: hidden;
} }
.bar { .bar {
height: 100%; height: 100%;
background: var(--color-primary); background: var(--primary-color, #f25843);
border-radius: var(--border-radius-sm); border-radius: 4px;
transition: width 0.3s ease; transition: width 0.3s ease;
min-width: 4px; min-width: 4px;
} }
@ -232,36 +233,36 @@
.barValue { .barValue {
width: 100px; width: 100px;
text-align: right; text-align: right;
font-size: var(--font-size-sm); font-size: 0.875rem;
color: var(--color-text-secondary); color: var(--text-secondary, #888);
font-family: var(--font-mono); font-family: monospace;
} }
.featureList { .featureList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-xs); gap: 0.25rem;
} }
.featureRow { .featureRow {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: var(--spacing-sm); padding: 0.5rem;
background: var(--color-bg-secondary); background: var(--bg-secondary, #2a2a2a);
border-radius: var(--border-radius-sm); border-radius: 4px;
} }
.featureLabel { .featureLabel {
font-size: var(--font-size-sm); font-size: 0.875rem;
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
text-transform: capitalize; text-transform: capitalize;
} }
.featureValue { .featureValue {
font-size: var(--font-size-sm); font-size: 0.875rem;
color: var(--color-text-secondary); color: var(--text-secondary, #888);
font-family: var(--font-mono); font-family: monospace;
} }
/* ============================================================================ /* ============================================================================
@ -275,46 +276,46 @@
.transactionsTable th, .transactionsTable th,
.transactionsTable td { .transactionsTable td {
padding: var(--spacing-sm) var(--spacing-md); padding: 0.75rem 1rem;
text-align: left; text-align: left;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--border-color, #333);
} }
.transactionsTable th { .transactionsTable th {
font-size: var(--font-size-xs); font-size: 0.75rem;
font-weight: var(--font-weight-semibold); font-weight: 600;
color: var(--color-text-secondary); color: var(--text-secondary, #888);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
background: var(--color-bg-secondary); background: var(--bg-secondary, #2a2a2a);
} }
.transactionsTable td { .transactionsTable td {
font-size: var(--font-size-sm); font-size: 0.875rem;
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
} }
.transactionType { .transactionType {
display: inline-block; display: inline-block;
padding: 2px 8px; padding: 2px 8px;
border-radius: var(--border-radius-sm); border-radius: 4px;
font-size: var(--font-size-xs); font-size: 0.75rem;
font-weight: var(--font-weight-medium); font-weight: 500;
} }
.transactionType.credit { .transactionType.credit {
background: var(--color-success-bg, rgba(40, 167, 69, 0.1)); background: rgba(40, 167, 69, 0.1);
color: var(--color-success, #28a745); color: #28a745;
} }
.transactionType.debit { .transactionType.debit {
background: var(--color-error-bg, rgba(220, 53, 69, 0.1)); background: rgba(220, 53, 69, 0.1);
color: var(--color-error, #dc3545); color: #dc3545;
} }
.transactionType.adjustment { .transactionType.adjustment {
background: var(--color-info-bg, rgba(23, 162, 184, 0.1)); background: rgba(23, 162, 184, 0.1);
color: var(--color-info, #17a2b8); color: #17a2b8;
} }
/* ============================================================================ /* ============================================================================
@ -322,86 +323,86 @@
============================================================================ */ ============================================================================ */
.adminSection { .adminSection {
background: var(--color-bg-card); background: var(--surface-color, #1e1e1e);
border: 1px solid var(--color-border); border: 1px solid var(--border-color, #333);
border-radius: var(--border-radius-lg); border-radius: 12px;
padding: var(--spacing-lg); padding: 1.5rem;
margin-bottom: var(--spacing-lg); margin-bottom: 1.5rem;
} }
.adminSection h3 { .adminSection h3 {
font-size: var(--font-size-base); font-size: 1rem;
font-weight: var(--font-weight-semibold); font-weight: 600;
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
margin: 0 0 var(--spacing-md) 0; margin: 0 0 1rem 0;
} }
.formRow { .formRow {
display: flex; display: flex;
gap: var(--spacing-md); gap: 1rem;
margin-bottom: var(--spacing-md); margin-bottom: 1rem;
} }
.formGroup { .formGroup {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-xs); gap: 0.25rem;
} }
.formGroup label { .formGroup label {
font-size: var(--font-size-sm); font-size: 0.875rem;
font-weight: var(--font-weight-medium); font-weight: 500;
color: var(--color-text-secondary); color: var(--text-secondary, #888);
} }
.input { .input {
padding: var(--spacing-sm); padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--border-color, #333);
border-radius: var(--border-radius-md); border-radius: 6px;
background: var(--color-bg-input); background: var(--surface-color, #1e1e1e);
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
font-size: var(--font-size-base); font-size: 0.875rem;
} }
.input:focus { .input:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--primary-color, #f25843);
} }
.accountsGrid { .accountsGrid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--spacing-md); gap: 1rem;
} }
.accountCard { .accountCard {
background: var(--color-bg-secondary); background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--color-border); border: 1px solid var(--border-color, #333);
border-radius: var(--border-radius-md); border-radius: 8px;
padding: var(--spacing-md); padding: 1rem;
} }
.accountCard h4 { .accountCard h4 {
font-size: var(--font-size-sm); font-size: 0.875rem;
font-weight: var(--font-weight-semibold); font-weight: 600;
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
margin: 0 0 var(--spacing-sm) 0; margin: 0 0 0.5rem 0;
} }
.accountInfo { .accountInfo {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-xs); gap: 0.25rem;
font-size: var(--font-size-sm); font-size: 0.875rem;
} }
.accountInfo span { .accountInfo span {
color: var(--color-text-secondary); color: var(--text-secondary, #888);
} }
.accountInfo strong { .accountInfo strong {
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
} }
/* ============================================================================ /* ============================================================================
@ -409,22 +410,22 @@
============================================================================ */ ============================================================================ */
.button { .button {
padding: var(--spacing-sm) var(--spacing-md); padding: 0.5rem 1rem;
border: none; border: none;
border-radius: var(--border-radius-md); border-radius: 6px;
font-size: var(--font-size-sm); font-size: 0.875rem;
font-weight: var(--font-weight-medium); font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.buttonPrimary { .buttonPrimary {
background: var(--color-primary); background: var(--primary-color, #f25843);
color: white; color: white;
} }
.buttonPrimary:hover { .buttonPrimary:hover {
background: var(--color-primary-dark); background: var(--primary-dark, #d94d3a);
} }
.buttonPrimary:disabled { .buttonPrimary:disabled {
@ -433,13 +434,13 @@
} }
.buttonSecondary { .buttonSecondary {
background: var(--color-bg-secondary); background: var(--bg-secondary, #2a2a2a);
color: var(--color-text-primary); color: var(--text-primary, #e0e0e0);
border: 1px solid var(--color-border); border: 1px solid var(--border-color, #333);
} }
.buttonSecondary:hover { .buttonSecondary:hover {
background: var(--color-bg-hover); background: var(--surface-color, #1e1e1e);
} }
/* ============================================================================ /* ============================================================================
@ -450,37 +451,37 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: var(--spacing-xl); padding: 2rem;
color: var(--color-text-secondary); color: var(--text-secondary, #888);
font-size: var(--font-size-sm); font-size: 0.875rem;
} }
.noData { .noData {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: var(--spacing-lg); padding: 1.5rem;
color: var(--color-text-tertiary); color: var(--text-tertiary, #666);
font-size: var(--font-size-sm); font-size: 0.875rem;
font-style: italic; font-style: italic;
} }
.errorMessage { .errorMessage {
background: var(--color-error-bg, rgba(220, 53, 69, 0.1)); background: rgba(220, 53, 69, 0.1);
color: var(--color-error, #dc3545); color: #dc3545;
padding: var(--spacing-sm) var(--spacing-md); padding: 0.75rem 1rem;
border-radius: var(--border-radius-md); border-radius: 6px;
font-size: var(--font-size-sm); font-size: 0.875rem;
margin-bottom: var(--spacing-md); margin-bottom: 1rem;
} }
.successMessage { .successMessage {
background: var(--color-success-bg, rgba(40, 167, 69, 0.1)); background: rgba(40, 167, 69, 0.1);
color: var(--color-success, #28a745); color: #28a745;
padding: var(--spacing-sm) var(--spacing-md); padding: 0.75rem 1rem;
border-radius: var(--border-radius-md); border-radius: 6px;
font-size: var(--font-size-sm); font-size: 0.875rem;
margin-bottom: var(--spacing-md); margin-bottom: 1rem;
} }
/* ============================================================================ /* ============================================================================
@ -489,7 +490,7 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.billingDashboard { .billingDashboard {
padding: var(--spacing-md); padding: 1rem;
} }
.balanceGrid { .balanceGrid {
@ -513,6 +514,6 @@
.barLabel, .barLabel,
.barValue { .barValue {
width: 80px; 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 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 { useAdminMandates } from '../../hooks/useMandates';
import styles from './Billing.module.css'; import styles from './Billing.module.css';
@ -191,10 +191,11 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
interface CreditAdderProps { interface CreditAdderProps {
settings: BillingSettings | null; settings: BillingSettings | null;
accounts: AccountSummary[]; accounts: AccountSummary[];
users: MandateUserSummary[];
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<void>; 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 [selectedUserId, setSelectedUserId] = useState<string>('');
const [amount, setAmount] = useState<number>(10); const [amount, setAmount] = useState<number>(10);
const [description, setDescription] = useState<string>('Manuelles Aufladen'); 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'; 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (amount <= 0) { if (amount <= 0) {
@ -246,21 +255,23 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, onAddCred
{isPrepayUser && ( {isPrepayUser && (
<div className={styles.formRow}> <div className={styles.formRow}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label>Benutzer-Konto</label> <label>Benutzer</label>
<select <select
className={styles.select} className={styles.select}
value={selectedUserId} value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)} onChange={(e) => setSelectedUserId(e.target.value)}
required required
> >
<option value="">-- Konto wählen --</option> <option value="">-- Benutzer wählen --</option>
{accounts {users.map((user) => {
.filter(acc => acc.accountType === 'USER') const account = accountsByUserId[user.id];
.map((acc) => ( const balanceInfo = account ? ` (${formatCurrency(account.balance)})` : ' (kein Konto)';
<option key={acc.id} value={acc.userId || ''}> return (
{acc.userId} - {formatCurrency(acc.balance)} <option key={user.id} value={user.id}>
{user.displayName || user.email || user.id}{balanceInfo}
</option> </option>
))} );
})}
</select> </select>
</div> </div>
</div> </div>
@ -355,7 +366,7 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, loading }
export const BillingAdmin: React.FC = () => { export const BillingAdmin: React.FC = () => {
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null); 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) => { const handleMandateSelect = (mandateId: string) => {
setSelectedMandateId(mandateId || null); setSelectedMandateId(mandateId || null);
@ -397,6 +408,7 @@ export const BillingAdmin: React.FC = () => {
<CreditAdder <CreditAdder
settings={settings} settings={settings}
accounts={accounts} accounts={accounts}
users={users}
onAddCredit={handleAddCredit} 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 { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { useVoiceLanguage, VoiceLanguageSelect, Messages } from '../../components/UiComponents'; 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 type { Message } from '../../components/UiComponents/Messages/MessagesTypes';
import api from '../../api'; import api from '../../api';
import styles from './PlaygroundPage.module.css'; import styles from './PlaygroundPage.module.css';
@ -58,8 +58,8 @@ export const PlaygroundPage: React.FC = () => {
deletingFiles, deletingFiles,
previewingFiles, previewingFiles,
downloadingFiles, downloadingFiles,
selectedProvider, selectedProviders,
onProviderSelect, onProvidersChange,
} = hookData; } = hookData;
const { prompts, refetch: refetchPrompts } = usePrompts(); 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 // Permission check - also show while loading
if (playgroundUIPermission === false) { if (playgroundUIPermission === false) {
return ( return (
@ -785,9 +782,9 @@ export const PlaygroundPage: React.FC = () => {
> >
<FaPlus /> <FaPlus />
</button> </button>
<ProviderSelect <ProviderMultiSelect
value={selectedProvider} selectedProviders={selectedProviders}
onChange={onProviderSelect} onChange={onProvidersChange}
showLabel={false} showLabel={false}
/> />
<VoiceLanguageSelect <VoiceLanguageSelect