billing initial

This commit is contained in:
ValueOn AG 2026-02-04 21:51:20 +01:00
parent ff1caba925
commit 919f6e4b7d
15 changed files with 2290 additions and 3 deletions

View file

@ -46,6 +46,9 @@ import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandat
// Basedata Pages (global)
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
// Billing Pages
import { BillingDashboard, BillingTransactions, BillingAdmin } from './pages/billing';
function App() {
// Load saved theme preference and set app name on app mount
useEffect(() => {
@ -110,6 +113,14 @@ function App() {
<Route path="connections" element={<ConnectionsPage />} />
</Route>
{/* ============================================== */}
{/* BILLING ROUTES */}
{/* ============================================== */}
<Route path="billing">
<Route index element={<BillingDashboard />} />
<Route path="transactions" element={<BillingTransactions />} />
</Route>
{/* ============================================== */}
{/* FEATURE-INSTANZ ROUTES */}
{/* /mandates/:mandateId/:featureCode/:instanceId */}
@ -165,6 +176,7 @@ function App() {
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
<Route path="billing" element={<BillingAdmin />} />
</Route>
</Route>

View file

@ -17,6 +17,7 @@ export interface Automation {
lastExecution?: number;
nextExecution?: number;
executionLogs?: AutomationLog[];
allowedProviders?: string[];
_createdAt?: number;
_updatedAt?: number;
_createdByUserName?: string;

254
src/api/billingApi.ts Normal file
View file

@ -0,0 +1,254 @@
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER' | 'CREDIT_POSTPAY' | 'UNLIMITED';
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
export interface BillingAddress {
company: string;
street: string;
zip: string;
city: string;
country: string;
vatNumber?: string;
}
export interface BillingBalance {
mandateId: string;
mandateName: string;
billingModel: BillingModel;
balance: number;
currency: string;
warningThreshold: number;
isWarning: boolean;
creditLimit?: number;
}
export interface BillingTransaction {
id: string;
accountId: string;
transactionType: TransactionType;
amount: number;
description: string;
referenceType?: ReferenceType;
workflowId?: string;
featureCode?: string;
aicoreProvider?: string;
createdAt?: string;
}
export interface BillingSettings {
id: string;
mandateId: string;
billingModel: BillingModel;
defaultUserCredit: number;
warningThresholdPercent: number;
blockOnZeroBalance: boolean;
notifyOnWarning: boolean;
notifyEmails: string[];
billingAddress?: BillingAddress;
}
export interface BillingSettingsUpdate {
billingModel?: BillingModel;
defaultUserCredit?: number;
warningThresholdPercent?: number;
blockOnZeroBalance?: boolean;
notifyOnWarning?: boolean;
notifyEmails?: string[];
billingAddress?: BillingAddress;
}
export interface UsageReport {
period: string;
totalCost: number;
transactionCount: number;
costByProvider: Record<string, number>;
costByFeature: Record<string, number>;
}
export interface AccountSummary {
id: string;
mandateId: string;
userId?: string;
accountType: string;
balance: number;
creditLimit?: number;
warningThreshold: number;
enabled: boolean;
}
export interface CreditAddRequest {
userId?: string;
amount: number;
description?: string;
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// USER API FUNCTIONS
// ============================================================================
/**
* Fetch billing balances for all mandates the user belongs to
* Endpoint: GET /api/billing/balance
*/
export async function fetchBalances(
request: ApiRequestFunction
): Promise<BillingBalance[]> {
return await request({
url: '/api/billing/balance',
method: 'get'
});
}
/**
* Fetch billing balance for a specific mandate
* Endpoint: GET /api/billing/balance/{mandateId}
*/
export async function fetchBalanceForMandate(
request: ApiRequestFunction,
mandateId: string
): Promise<BillingBalance> {
return await request({
url: `/api/billing/balance/${mandateId}`,
method: 'get'
});
}
/**
* Fetch transaction history
* Endpoint: GET /api/billing/transactions
*/
export async function fetchTransactions(
request: ApiRequestFunction,
limit: number = 50,
offset: number = 0
): Promise<BillingTransaction[]> {
return await request({
url: '/api/billing/transactions',
method: 'get',
params: { limit, offset }
});
}
/**
* Fetch usage statistics
* Endpoint: GET /api/billing/statistics/{period}
*/
export async function fetchStatistics(
request: ApiRequestFunction,
period: 'day' | 'month' | 'year',
year: number,
month?: number
): Promise<UsageReport> {
const params: Record<string, any> = { year };
if (month !== undefined) {
params.month = month;
}
return await request({
url: `/api/billing/statistics/${period}`,
method: 'get',
params
});
}
/**
* Fetch allowed AICore providers
* Endpoint: GET /api/billing/providers
*/
export async function fetchAllowedProviders(
request: ApiRequestFunction
): Promise<string[]> {
return await request({
url: '/api/billing/providers',
method: 'get'
});
}
// ============================================================================
// ADMIN API FUNCTIONS
// ============================================================================
/**
* Fetch billing settings for a mandate (Admin)
* Endpoint: GET /api/billing/admin/settings/{mandateId}
*/
export async function fetchSettingsAdmin(
request: ApiRequestFunction,
mandateId: string
): Promise<BillingSettings> {
return await request({
url: `/api/billing/admin/settings/${mandateId}`,
method: 'get'
});
}
/**
* Create or update billing settings (Admin)
* Endpoint: POST /api/billing/admin/settings/{mandateId}
*/
export async function updateSettingsAdmin(
request: ApiRequestFunction,
mandateId: string,
settings: BillingSettingsUpdate
): Promise<BillingSettings> {
return await request({
url: `/api/billing/admin/settings/${mandateId}`,
method: 'post',
data: settings
});
}
/**
* Add credit to an account (Admin)
* Endpoint: POST /api/billing/admin/credit/{mandateId}
*/
export async function addCreditAdmin(
request: ApiRequestFunction,
mandateId: string,
creditRequest: CreditAddRequest
): Promise<BillingTransaction> {
return await request({
url: `/api/billing/admin/credit/${mandateId}`,
method: 'post',
data: creditRequest
});
}
/**
* Fetch all accounts for a mandate (Admin)
* Endpoint: GET /api/billing/admin/accounts/{mandateId}
*/
export async function fetchAccountsAdmin(
request: ApiRequestFunction,
mandateId: string
): Promise<AccountSummary[]> {
return await request({
url: `/api/billing/admin/accounts/${mandateId}`,
method: 'get'
});
}
/**
* Fetch all transactions for a mandate (Admin)
* Endpoint: GET /api/billing/admin/transactions/{mandateId}
*/
export async function fetchTransactionsAdmin(
request: ApiRequestFunction,
mandateId: string,
limit: number = 100
): Promise<BillingTransaction[]> {
return await request({
url: `/api/billing/admin/transactions/${mandateId}`,
method: 'get',
params: { limit }
});
}

View file

@ -13,6 +13,7 @@
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { FaTimes, FaSave, FaChevronLeft, FaChevronRight, FaRocket, FaFileAlt, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa';
import { ActionsPanel } from '../ActionsPanel';
import { ProviderMultiSelect } from '../ProviderSelector';
import { useToast } from '../../contexts/ToastContext';
import { useLanguage } from '../../providers/language/LanguageContext';
import { useWorkflowActions } from '../../hooks/useAutomations';
@ -368,6 +369,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
const [label, setLabel] = useState('');
const [schedule, setSchedule] = useState('0 22 * * *');
const [active, setActive] = useState(false);
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
// Template multilingual fields
const [labelMulti, setLabelMulti] = useState<LocalTextMultilingual>({ en: '', de: '' });
@ -530,6 +532,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
setLabel(def.label || '');
setSchedule(def.schedule || '0 22 * * *');
setActive(def.active ?? false);
setAllowedProviders(def.allowedProviders || []);
}
// Extract template JSON
@ -684,7 +687,8 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
schedule,
active,
template: templateJson,
placeholders
placeholders,
allowedProviders
};
}
@ -700,7 +704,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
} finally {
setIsSaving(false);
}
}, [label, schedule, active, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]);
}, [label, schedule, active, allowedProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]);
// Computed values
const editorTitle = title || (mode === 'template'
@ -831,6 +835,18 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
Automatisierung ist aktiv und wird planmässig ausgeführt
</p>
</div>
{/* Allowed AI Providers */}
<div className={styles.formGroup}>
<ProviderMultiSelect
selectedProviders={allowedProviders}
onChange={setAllowedProviders}
label="Erlaubte AI-Provider"
/>
<p className={styles.formHint}>
Beschränkt die Automation auf bestimmte AI-Provider. Leer = alle erlaubt.
</p>
</div>
</div>
)}

View file

@ -0,0 +1,161 @@
/* Provider Selector Component Styles */
/* ============================================================================
SINGLE SELECT
============================================================================ */
.providerSelect {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 4px);
}
.label {
font-size: var(--font-size-sm, 0.875rem);
font-weight: var(--font-weight-medium, 500);
color: var(--color-text-secondary);
}
.select {
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;
min-width: 150px;
}
.select:focus {
outline: none;
border-color: var(--color-primary);
}
.select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ============================================================================
MULTI SELECT
============================================================================ */
.providerMultiSelect {
display: flex;
flex-direction: column;
gap: var(--spacing-sm, 8px);
}
.selectActions {
display: flex;
gap: var(--spacing-xs, 4px);
}
.actionButton {
padding: 2px 8px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm, 4px);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
font-size: var(--font-size-xs, 0.75rem);
cursor: pointer;
transition: all 0.2s ease;
}
.actionButton:hover:not(:disabled) {
background: var(--color-bg-hover);
border-color: var(--color-primary);
}
.actionButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkboxList {
display: flex;
flex-direction: column;
gap: var(--spacing-xs, 4px);
padding: var(--spacing-sm, 8px);
background: var(--color-bg-secondary);
border-radius: var(--border-radius-md, 6px);
}
.checkboxItem {
display: flex;
align-items: center;
gap: var(--spacing-sm, 8px);
padding: var(--spacing-xs, 4px) var(--spacing-sm, 8px);
border-radius: var(--border-radius-sm, 4px);
cursor: pointer;
transition: background 0.2s ease;
}
.checkboxItem:hover {
background: var(--color-bg-hover);
}
.checkboxItem.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkboxItem input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: inherit;
}
.icon {
font-size: 1.1em;
}
.providerName {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text-primary);
}
.hint {
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-tertiary);
font-style: italic;
padding: var(--spacing-xs, 4px) 0;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md, 16px);
color: var(--color-text-secondary);
font-size: var(--font-size-sm, 0.875rem);
}
/* ============================================================================
PROVIDER BADGES
============================================================================ */
.providerBadges {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs, 4px);
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm, 4px);
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-primary);
}
.allProviders {
font-size: var(--font-size-xs, 0.75rem);
color: var(--color-text-secondary);
font-style: italic;
}

View file

@ -0,0 +1,221 @@
/**
* ProviderSelector Component
*
* Wiederverwendbare Komponente zur Auswahl von AICore-Providern.
* Kann im Chat Playground und Automation Editor verwendet werden.
*
* Features:
* - Dropdown für Einzelauswahl
* - Checkbox-Liste für Mehrfachauswahl
* - Lädt verfügbare Provider aus dem Billing-System
*/
import React, { useEffect, useMemo } from 'react';
import { useBilling } from '../../hooks/useBilling';
import styles from './ProviderSelector.module.css';
// Provider display names
const PROVIDER_LABELS: Record<string, string> = {
anthropic: 'Anthropic (Claude)',
openai: 'OpenAI (GPT)',
perplexity: 'Perplexity',
tavily: 'Tavily (Web Search)',
internal: 'Internal',
};
// Provider icons (emojis for simplicity)
const PROVIDER_ICONS: Record<string, string> = {
anthropic: '🤖',
openai: '💬',
perplexity: '🔍',
tavily: '🌐',
internal: '🏠',
};
// ============================================================================
// SINGLE SELECT COMPONENT
// ============================================================================
interface ProviderSelectProps {
value: string;
onChange: (provider: string) => void;
disabled?: boolean;
className?: string;
label?: string;
showLabel?: boolean;
}
export const ProviderSelect: React.FC<ProviderSelectProps> = ({
value,
onChange,
disabled = false,
className,
label = 'AI-Provider',
showLabel = true,
}) => {
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => {
if (allowedProviders.length === 0 && !loading) {
loadAllowedProviders();
}
}, []);
const providerOptions = useMemo(() => {
return allowedProviders.map((provider) => ({
value: provider,
label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`,
}));
}, [allowedProviders]);
return (
<div className={`${styles.providerSelect} ${className || ''}`}>
{showLabel && <label className={styles.label}>{label}</label>}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled || loading}
className={styles.select}
>
<option value="">-- Auto --</option>
{providerOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
};
// ============================================================================
// MULTI SELECT COMPONENT (Checkbox List)
// ============================================================================
interface ProviderMultiSelectProps {
selectedProviders: string[];
onChange: (providers: string[]) => void;
disabled?: boolean;
className?: string;
label?: string;
showLabel?: boolean;
}
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
selectedProviders,
onChange,
disabled = false,
className,
label = 'Erlaubte AI-Provider',
showLabel = true,
}) => {
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => {
if (allowedProviders.length === 0 && !loading) {
loadAllowedProviders();
}
}, []);
const handleToggle = (provider: string) => {
if (selectedProviders.includes(provider)) {
onChange(selectedProviders.filter((p) => p !== provider));
} else {
onChange([...selectedProviders, provider]);
}
};
const handleSelectAll = () => {
onChange(allowedProviders);
};
const handleSelectNone = () => {
onChange([]);
};
return (
<div className={`${styles.providerMultiSelect} ${className || ''}`}>
{showLabel && <label className={styles.label}>{label}</label>}
<div className={styles.selectActions}>
<button
type="button"
onClick={handleSelectAll}
disabled={disabled}
className={styles.actionButton}
>
Alle
</button>
<button
type="button"
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
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>
);
};
// ============================================================================
// COMPACT PROVIDER BADGE LIST
// ============================================================================
interface ProviderBadgesProps {
providers: string[];
className?: string;
}
export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
providers,
className,
}) => {
if (providers.length === 0) {
return <span className={styles.allProviders}>Alle Provider</span>;
}
return (
<div className={`${styles.providerBadges} ${className || ''}`}>
{providers.map((provider) => (
<span key={provider} className={styles.badge}>
{PROVIDER_ICONS[provider] || '🔌'} {PROVIDER_LABELS[provider] || provider}
</span>
))}
</div>
);
};
// Default export
export default ProviderSelect;

View file

@ -0,0 +1,10 @@
/**
* Provider Selector Component Exports
*/
export {
ProviderSelect,
ProviderMultiSelect,
ProviderBadges
} from './ProviderSelector';
export { default } from './ProviderSelector';

View file

@ -20,7 +20,7 @@ import {
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaChartBar, FaMicrophone,
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
FaProjectDiagram, FaMapMarkedAlt
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt
} from 'react-icons/fa';
// =============================================================================
@ -47,6 +47,10 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.system.pek': <FaChartBar />,
'page.system.speech': <FaMicrophone />,
// Billing pages
'page.billing.dashboard': <FaWallet />,
'page.billing.transactions': <FaListAlt />,
// Admin pages
'page.admin.access': <FaBuilding />,
'page.admin.users': <FaUsers />,
@ -59,6 +63,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.admin.feature-instances': <FaCubes />,
'page.admin.feature-users': <FaUsersCog />,
'page.admin.user-access-overview': <FaUserShield />,
'page.admin.billing': <FaMoneyBillAlt />,
// Feature pages - Trustee
'page.feature.trustee.dashboard': <FaChartLine />,

265
src/hooks/useBilling.ts Normal file
View file

@ -0,0 +1,265 @@
/**
* useBilling Hook
*
* Hook für die Verwaltung von Billing-Daten.
* Bietet Zugriff auf Guthaben, Transaktionen, Statistiken und Admin-Funktionen.
*/
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import {
fetchBalances,
fetchBalanceForMandate,
fetchTransactions,
fetchStatistics,
fetchAllowedProviders,
fetchSettingsAdmin,
updateSettingsAdmin,
addCreditAdmin,
fetchAccountsAdmin,
fetchTransactionsAdmin,
type BillingBalance,
type BillingTransaction,
type BillingSettings,
type BillingSettingsUpdate,
type UsageReport,
type AccountSummary,
type CreditAddRequest,
} from '../api/billingApi';
// Re-export types
export type {
BillingBalance,
BillingTransaction,
BillingSettings,
BillingSettingsUpdate,
UsageReport,
AccountSummary,
CreditAddRequest,
};
export type { BillingModel, TransactionType, ReferenceType } from '../api/billingApi';
/**
* Hook for user billing operations
*/
export function useBilling() {
const [balances, setBalances] = useState<BillingBalance[]>([]);
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
const [statistics, setStatistics] = useState<UsageReport | null>(null);
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
const { request, isLoading: loading, error } = useApiRequest();
// Fetch all balances for the user
const loadBalances = useCallback(async () => {
try {
const data = await fetchBalances(request);
setBalances(Array.isArray(data) ? data : []);
return data;
} catch (err) {
console.error('Error loading balances:', err);
setBalances([]);
return [];
}
}, [request]);
// Fetch balance for a specific mandate
const loadBalanceForMandate = useCallback(async (mandateId: string) => {
try {
return await fetchBalanceForMandate(request, mandateId);
} catch (err) {
console.error('Error loading balance for mandate:', err);
return null;
}
}, [request]);
// Fetch transactions
const loadTransactions = useCallback(async (limit: number = 50, offset: number = 0) => {
try {
const data = await fetchTransactions(request, limit, offset);
setTransactions(Array.isArray(data) ? data : []);
return data;
} catch (err) {
console.error('Error loading transactions:', err);
setTransactions([]);
return [];
}
}, [request]);
// Fetch statistics
const loadStatistics = useCallback(async (
period: 'day' | 'month' | 'year',
year: number,
month?: number
) => {
try {
const data = await fetchStatistics(request, period, year, month);
setStatistics(data);
return data;
} catch (err) {
console.error('Error loading statistics:', err);
setStatistics(null);
return null;
}
}, [request]);
// Fetch allowed providers
const loadAllowedProviders = useCallback(async () => {
try {
const data = await fetchAllowedProviders(request);
setAllowedProviders(Array.isArray(data) ? data : []);
return data;
} catch (err) {
console.error('Error loading allowed providers:', err);
setAllowedProviders([]);
return [];
}
}, [request]);
// Initial load
useEffect(() => {
loadBalances();
loadAllowedProviders();
}, []);
return {
balances,
transactions,
statistics,
allowedProviders,
loading,
error,
loadBalances,
loadBalanceForMandate,
loadTransactions,
loadStatistics,
loadAllowedProviders,
refetch: loadBalances,
};
}
/**
* Hook for admin billing operations
*/
export function useBillingAdmin(mandateId?: string) {
const [settings, setSettings] = useState<BillingSettings | null>(null);
const [accounts, setAccounts] = useState<AccountSummary[]>([]);
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
const { request, isLoading: loading, error } = useApiRequest();
// Fetch settings for a mandate
const loadSettings = useCallback(async (targetMandateId?: string) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
try {
const data = await fetchSettingsAdmin(request, mId);
setSettings(data);
return data;
} catch (err) {
console.error('Error loading billing settings:', err);
setSettings(null);
return null;
}
}, [request, mandateId]);
// Update settings
const saveSettings = useCallback(async (
settingsUpdate: BillingSettingsUpdate,
targetMandateId?: string
) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
try {
const data = await updateSettingsAdmin(request, mId, settingsUpdate);
setSettings(data);
return data;
} catch (err) {
console.error('Error saving billing settings:', err);
throw err;
}
}, [request, mandateId]);
// Add credit
const addCredit = useCallback(async (
creditRequest: CreditAddRequest,
targetMandateId?: string
) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
try {
const result = await addCreditAdmin(request, mId, creditRequest);
// Reload accounts after adding credit
await loadAccounts(mId);
return result;
} catch (err) {
console.error('Error adding credit:', err);
throw err;
}
}, [request, mandateId]);
// Fetch accounts for a mandate
const loadAccounts = useCallback(async (targetMandateId?: string) => {
const mId = targetMandateId || mandateId;
if (!mId) return [];
try {
const data = await fetchAccountsAdmin(request, mId);
setAccounts(Array.isArray(data) ? data : []);
return data;
} catch (err) {
console.error('Error loading accounts:', err);
setAccounts([]);
return [];
}
}, [request, mandateId]);
// Fetch transactions for a mandate
const loadTransactions = useCallback(async (targetMandateId?: string, limit: number = 100) => {
const mId = targetMandateId || mandateId;
if (!mId) return [];
try {
const data = await fetchTransactionsAdmin(request, mId, limit);
setTransactions(Array.isArray(data) ? data : []);
return data;
} catch (err) {
console.error('Error loading transactions:', err);
setTransactions([]);
return [];
}
}, [request, mandateId]);
// Load data when mandateId changes
useEffect(() => {
if (mandateId) {
loadSettings();
loadAccounts();
loadTransactions();
}
}, [mandateId]);
return {
settings,
accounts,
transactions,
loading,
error,
loadSettings,
saveSettings,
addCredit,
loadAccounts,
loadTransactions,
refetch: () => {
if (mandateId) {
loadSettings();
loadAccounts();
loadTransactions();
}
},
};
}
export default useBilling;

View file

@ -0,0 +1,518 @@
/* Billing Pages Styles */
/* ============================================================================
PAGE LAYOUT
============================================================================ */
.billingDashboard {
padding: var(--spacing-lg);
max-width: 1200px;
margin: 0 auto;
}
.pageHeader {
margin-bottom: var(--spacing-xl);
}
.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;
}
.subtitle {
font-size: var(--font-size-base);
color: var(--color-text-secondary);
margin: 0;
}
/* ============================================================================
SECTIONS
============================================================================ */
.section {
margin-bottom: var(--spacing-xl);
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
flex-wrap: wrap;
gap: var(--spacing-sm);
}
.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;
}
.sectionHeader .sectionTitle {
margin-bottom: 0;
}
/* ============================================================================
BALANCE CARDS
============================================================================ */
.balanceGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-md);
}
.balanceCard {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-lg);
cursor: pointer;
transition: all 0.2s ease;
}
.balanceCard:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
}
.balanceCard.warning {
border-color: var(--color-warning);
background: var(--color-warning-bg, rgba(255, 193, 7, 0.1));
}
.balanceHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-md);
}
.mandateName {
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin: 0;
}
.billingModel {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
padding: 2px 8px;
border-radius: var(--border-radius-sm);
}
.balanceAmount {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
margin-bottom: var(--spacing-sm);
}
.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));
padding: 4px 8px;
border-radius: var(--border-radius-sm);
font-weight: var(--font-weight-medium);
}
/* ============================================================================
STATISTICS
============================================================================ */
.periodSelector {
display: flex;
gap: var(--spacing-sm);
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);
cursor: pointer;
}
.select:focus {
outline: none;
border-color: var(--color-primary);
}
.statisticsChart {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-lg);
padding: var(--spacing-lg);
}
.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);
}
.totalLabel {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin-bottom: var(--spacing-xs);
}
.totalAmount {
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.chartSection {
margin-bottom: var(--spacing-lg);
}
.chartSection:last-child {
margin-bottom: 0;
}
.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;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.barChart {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.barRow {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.barLabel {
width: 100px;
font-size: var(--font-size-sm);
color: var(--color-text-primary);
text-transform: capitalize;
}
.barContainer {
flex: 1;
height: 24px;
background: var(--color-bg-secondary);
border-radius: var(--border-radius-sm);
overflow: hidden;
}
.bar {
height: 100%;
background: var(--color-primary);
border-radius: var(--border-radius-sm);
transition: width 0.3s ease;
min-width: 4px;
}
.barValue {
width: 100px;
text-align: right;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
font-family: var(--font-mono);
}
.featureList {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.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);
}
.featureLabel {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
text-transform: capitalize;
}
.featureValue {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
font-family: var(--font-mono);
}
/* ============================================================================
TRANSACTIONS
============================================================================ */
.transactionsTable {
width: 100%;
border-collapse: collapse;
}
.transactionsTable th,
.transactionsTable td {
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.transactionsTable th {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
background: var(--color-bg-secondary);
}
.transactionsTable td {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
}
.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);
}
.transactionType.credit {
background: var(--color-success-bg, rgba(40, 167, 69, 0.1));
color: var(--color-success, #28a745);
}
.transactionType.debit {
background: var(--color-error-bg, rgba(220, 53, 69, 0.1));
color: var(--color-error, #dc3545);
}
.transactionType.adjustment {
background: var(--color-info-bg, rgba(23, 162, 184, 0.1));
color: var(--color-info, #17a2b8);
}
/* ============================================================================
ADMIN STYLES
============================================================================ */
.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);
}
.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;
}
.formRow {
display: flex;
gap: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.formGroup {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.formGroup label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.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);
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.accountsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--spacing-md);
}
.accountCard {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
padding: var(--spacing-md);
}
.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;
}
.accountInfo {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
font-size: var(--font-size-sm);
}
.accountInfo span {
color: var(--color-text-secondary);
}
.accountInfo strong {
color: var(--color-text-primary);
}
/* ============================================================================
BUTTONS
============================================================================ */
.button {
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: var(--border-radius-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.2s ease;
}
.buttonPrimary {
background: var(--color-primary);
color: white;
}
.buttonPrimary:hover {
background: var(--color-primary-dark);
}
.buttonPrimary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.buttonSecondary {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.buttonSecondary:hover {
background: var(--color-bg-hover);
}
/* ============================================================================
UTILITY CLASSES
============================================================================ */
.loadingPlaceholder {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-xl);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.noData {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-lg);
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
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);
}
.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);
}
/* ============================================================================
RESPONSIVE
============================================================================ */
@media (max-width: 768px) {
.billingDashboard {
padding: var(--spacing-md);
}
.balanceGrid {
grid-template-columns: 1fr;
}
.sectionHeader {
flex-direction: column;
align-items: flex-start;
}
.periodSelector {
width: 100%;
flex-wrap: wrap;
}
.formRow {
flex-direction: column;
}
.barLabel,
.barValue {
width: 80px;
font-size: var(--font-size-xs);
}
}

View file

@ -0,0 +1,419 @@
/**
* Billing Admin Page
*
* Admin-Seite für Billing-Verwaltung (SysAdmin only).
* - Settings verwalten
* - Guthaben aufladen
* - Konten übersicht
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useBillingAdmin, type BillingSettings, type AccountSummary } from '../../hooks/useBilling';
import { useAdminMandates } from '../../hooks/useMandates';
import styles from './Billing.module.css';
// ============================================================================
// MANDATE SELECTOR
// ============================================================================
interface MandateSelectorProps {
selectedMandateId: string | null;
onSelect: (mandateId: string) => void;
}
const MandateSelector: React.FC<MandateSelectorProps> = ({ selectedMandateId, onSelect }) => {
const { mandates, loading } = useAdminMandates();
return (
<div className={styles.formGroup}>
<label>Mandant auswählen</label>
<select
className={styles.select}
value={selectedMandateId || ''}
onChange={(e) => onSelect(e.target.value)}
disabled={loading}
>
<option value="">-- Mandant wählen --</option>
{mandates.map((mandate) => (
<option key={mandate.id} value={mandate.id}>
{mandate.name || mandate.id}
</option>
))}
</select>
</div>
);
};
// ============================================================================
// SETTINGS EDITOR
// ============================================================================
interface SettingsEditorProps {
settings: BillingSettings | null;
onSave: (settings: Partial<BillingSettings>) => Promise<void>;
loading: boolean;
}
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
const [formData, setFormData] = useState({
billingModel: settings?.billingModel || 'UNLIMITED',
defaultUserCredit: settings?.defaultUserCredit || 10,
warningThresholdPercent: settings?.warningThresholdPercent || 10,
blockOnZeroBalance: settings?.blockOnZeroBalance ?? true,
notifyOnWarning: settings?.notifyOnWarning ?? true,
});
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
useEffect(() => {
if (settings) {
setFormData({
billingModel: settings.billingModel,
defaultUserCredit: settings.defaultUserCredit,
warningThresholdPercent: settings.warningThresholdPercent,
blockOnZeroBalance: settings.blockOnZeroBalance,
notifyOnWarning: settings.notifyOnWarning,
});
}
}, [settings]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setMessage(null);
try {
await onSave(formData);
setMessage({ type: 'success', text: 'Einstellungen gespeichert!' });
} catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Fehler beim Speichern' });
} finally {
setSaving(false);
}
};
return (
<div className={styles.adminSection}>
<h3>Billing-Einstellungen</h3>
{message && (
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit}>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Abrechnungsmodell</label>
<select
className={styles.select}
value={formData.billingModel}
onChange={(e) => setFormData(prev => ({ ...prev, billingModel: e.target.value as any }))}
>
<option value="UNLIMITED">Unlimited</option>
<option value="PREPAY_MANDATE">Prepaid (Mandant)</option>
<option value="PREPAY_USER">Prepaid (Benutzer)</option>
<option value="CREDIT_POSTPAY">Kredit (Postpay)</option>
</select>
</div>
<div className={styles.formGroup}>
<label>Standard-Guthaben (CHF)</label>
<input
type="number"
className={styles.input}
value={formData.defaultUserCredit}
onChange={(e) => setFormData(prev => ({ ...prev, defaultUserCredit: Number(e.target.value) }))}
min="0"
step="0.01"
/>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Warnschwelle (%)</label>
<input
type="number"
className={styles.input}
value={formData.warningThresholdPercent}
onChange={(e) => setFormData(prev => ({ ...prev, warningThresholdPercent: Number(e.target.value) }))}
min="0"
max="100"
step="1"
/>
</div>
<div className={styles.formGroup}>
<label>&nbsp;</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.blockOnZeroBalance}
onChange={(e) => setFormData(prev => ({ ...prev, blockOnZeroBalance: e.target.checked }))}
/>
Bei Guthaben 0 blockieren
</label>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>&nbsp;</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.notifyOnWarning}
onChange={(e) => setFormData(prev => ({ ...prev, notifyOnWarning: e.target.checked }))}
/>
Bei Warnung benachrichtigen
</label>
</div>
</div>
<button
type="submit"
className={`${styles.button} ${styles.buttonPrimary}`}
disabled={saving || loading}
>
{saving ? 'Speichern...' : 'Einstellungen speichern'}
</button>
</form>
</div>
);
};
// ============================================================================
// CREDIT ADDER
// ============================================================================
interface CreditAdderProps {
settings: BillingSettings | null;
accounts: AccountSummary[];
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<void>;
}
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, onAddCredit }) => {
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [amount, setAmount] = useState<number>(10);
const [description, setDescription] = useState<string>('Manuelles Aufladen');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const isPrepayUser = settings?.billingModel === 'PREPAY_USER';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (amount <= 0) {
setMessage({ type: 'error', text: 'Betrag muss positiv sein' });
return;
}
setSaving(true);
setMessage(null);
try {
await onAddCredit(isPrepayUser ? selectedUserId : undefined, amount, description);
setMessage({ type: 'success', text: `${amount} CHF erfolgreich gutgeschrieben!` });
setAmount(10);
setDescription('Manuelles Aufladen');
} catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' });
} finally {
setSaving(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
return (
<div className={styles.adminSection}>
<h3>Guthaben aufladen</h3>
{message && (
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit}>
{isPrepayUser && (
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Benutzer-Konto</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>
))}
</select>
</div>
</div>
)}
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Betrag (CHF)</label>
<input
type="number"
className={styles.input}
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
min="0.01"
step="0.01"
required
/>
</div>
<div className={styles.formGroup}>
<label>Beschreibung</label>
<input
type="text"
className={styles.input}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Grund für Gutschrift"
/>
</div>
</div>
<button
type="submit"
className={`${styles.button} ${styles.buttonPrimary}`}
disabled={saving || (isPrepayUser && !selectedUserId)}
>
{saving ? 'Aufladen...' : 'Guthaben aufladen'}
</button>
</form>
</div>
);
};
// ============================================================================
// ACCOUNTS OVERVIEW
// ============================================================================
interface AccountsOverviewProps {
accounts: AccountSummary[];
loading: boolean;
}
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, loading }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
if (loading) {
return <div className={styles.loadingPlaceholder}>Lade Konten...</div>;
}
if (accounts.length === 0) {
return <div className={styles.noData}>Keine Konten vorhanden</div>;
}
return (
<div className={styles.adminSection}>
<h3>Konten</h3>
<div className={styles.accountsGrid}>
{accounts.map((account) => (
<div key={account.id} className={styles.accountCard}>
<h4>{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}</h4>
<div className={styles.accountInfo}>
{account.userId && <span>User: {account.userId}</span>}
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
{account.creditLimit && <span>Limit: {formatCurrency(account.creditLimit)}</span>}
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
</div>
</div>
))}
</div>
</div>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingAdmin: React.FC = () => {
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const { settings, accounts, loading, loadSettings, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined);
const handleMandateSelect = (mandateId: string) => {
setSelectedMandateId(mandateId || null);
};
const handleSaveSettings = useCallback(async (settingsUpdate: Partial<BillingSettings>) => {
if (!selectedMandateId) return;
await saveSettings(settingsUpdate);
}, [selectedMandateId, saveSettings]);
const handleAddCredit = useCallback(async (userId: string | undefined, amount: number, description: string) => {
if (!selectedMandateId) return;
await addCredit({ userId, amount, description });
await loadAccounts();
}, [selectedMandateId, addCredit, loadAccounts]);
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Billing Administration</h1>
<p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p>
</header>
<section className={styles.section}>
<MandateSelector
selectedMandateId={selectedMandateId}
onSelect={handleMandateSelect}
/>
</section>
{selectedMandateId && (
<>
<SettingsEditor
settings={settings}
onSave={handleSaveSettings}
loading={loading}
/>
<CreditAdder
settings={settings}
accounts={accounts}
onAddCredit={handleAddCredit}
/>
<AccountsOverview
accounts={accounts}
loading={loading}
/>
</>
)}
{!selectedMandateId && (
<div className={styles.noData}>
Bitte wählen Sie einen Mandanten aus.
</div>
)}
</div>
);
};
export default BillingAdmin;

View file

@ -0,0 +1,247 @@
/**
* Billing Dashboard Page
*
* Zeigt Guthaben, Statistiken und Transaktionen für den Benutzer.
*/
import React, { useState, useEffect, useMemo } from 'react';
import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling';
import styles from './Billing.module.css';
// ============================================================================
// BALANCE CARD COMPONENT
// ============================================================================
interface BalanceCardProps {
balance: BillingBalance;
onClick?: () => void;
}
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
const getBillingModelLabel = (model: string) => {
switch (model) {
case 'PREPAY_MANDATE': return 'Prepaid (Mandant)';
case 'PREPAY_USER': return 'Prepaid (Benutzer)';
case 'CREDIT_POSTPAY': return 'Kredit';
case 'UNLIMITED': return 'Unlimited';
default: return model;
}
};
return (
<div
className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}
onClick={onClick}
>
<div className={styles.balanceHeader}>
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
<span className={styles.billingModel}>{getBillingModelLabel(balance.billingModel)}</span>
</div>
<div className={styles.balanceAmount}>
{formatCurrency(balance.balance)}
</div>
{balance.isWarning && (
<div className={styles.warningBadge}>
Niedriges Guthaben
</div>
)}
</div>
);
};
// ============================================================================
// STATISTICS CHART COMPONENT
// ============================================================================
interface StatisticsChartProps {
statistics: UsageReport | null;
loading?: boolean;
}
const StatisticsChart: React.FC<StatisticsChartProps> = ({ statistics, loading }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
if (loading) {
return <div className={styles.loadingPlaceholder}>Lade Statistiken...</div>;
}
if (!statistics) {
return <div className={styles.noData}>Keine Statistiken verfügbar</div>;
}
// Calculate max cost for bar scaling
const maxProviderCost = Math.max(...Object.values(statistics.costByProvider), 1);
return (
<div className={styles.statisticsChart}>
<div className={styles.totalCost}>
<span className={styles.totalLabel}>Gesamtkosten</span>
<span className={styles.totalAmount}>{formatCurrency(statistics.totalCost)}</span>
</div>
<div className={styles.chartSection}>
<h4>Kosten nach Anbieter</h4>
{Object.entries(statistics.costByProvider).length === 0 ? (
<div className={styles.noData}>Keine Daten</div>
) : (
<div className={styles.barChart}>
{Object.entries(statistics.costByProvider).map(([provider, cost]) => (
<div key={provider} className={styles.barRow}>
<span className={styles.barLabel}>{provider}</span>
<div className={styles.barContainer}>
<div
className={styles.bar}
style={{ width: `${(cost / maxProviderCost) * 100}%` }}
/>
</div>
<span className={styles.barValue}>{formatCurrency(cost)}</span>
</div>
))}
</div>
)}
</div>
<div className={styles.chartSection}>
<h4>Kosten nach Feature</h4>
{Object.entries(statistics.costByFeature).length === 0 ? (
<div className={styles.noData}>Keine Daten</div>
) : (
<div className={styles.featureList}>
{Object.entries(statistics.costByFeature).map(([feature, cost]) => (
<div key={feature} className={styles.featureRow}>
<span className={styles.featureLabel}>{feature}</span>
<span className={styles.featureValue}>{formatCurrency(cost)}</span>
</div>
))}
</div>
)}
</div>
</div>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingDashboard: React.FC = () => {
const {
balances,
statistics,
loading,
loadBalances,
loadStatistics
} = useBilling();
const [selectedPeriod, setSelectedPeriod] = useState<'month' | 'year'>('month');
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1);
// Load statistics when period changes
useEffect(() => {
if (selectedPeriod === 'month') {
loadStatistics('month', selectedYear);
} else {
loadStatistics('year', selectedYear);
}
}, [selectedPeriod, selectedYear, loadStatistics]);
// Available years (current and last 2 years)
const availableYears = useMemo(() => {
const current = new Date().getFullYear();
return [current, current - 1, current - 2];
}, []);
// Available months
const availableMonths = [
{ value: 1, label: 'Januar' },
{ value: 2, label: 'Februar' },
{ value: 3, label: 'März' },
{ value: 4, label: 'April' },
{ value: 5, label: 'Mai' },
{ value: 6, label: 'Juni' },
{ value: 7, label: 'Juli' },
{ value: 8, label: 'August' },
{ value: 9, label: 'September' },
{ value: 10, label: 'Oktober' },
{ value: 11, label: 'November' },
{ value: 12, label: 'Dezember' },
];
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Billing</h1>
<p className={styles.subtitle}>Übersicht über Guthaben und Nutzung</p>
</header>
{/* Balance Cards */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Guthaben</h2>
{loading ? (
<div className={styles.loadingPlaceholder}>Lade Guthaben...</div>
) : balances.length === 0 ? (
<div className={styles.noData}>Keine Abrechnungskonten vorhanden</div>
) : (
<div className={styles.balanceGrid}>
{balances.map((balance) => (
<BalanceCard key={balance.mandateId} balance={balance} />
))}
</div>
)}
</section>
{/* Statistics */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>Nutzungsstatistik</h2>
<div className={styles.periodSelector}>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value as 'month' | 'year')}
className={styles.select}
>
<option value="month">Monat</option>
<option value="year">Jahr</option>
</select>
<select
value={selectedYear}
onChange={(e) => setSelectedYear(Number(e.target.value))}
className={styles.select}
>
{availableYears.map((year) => (
<option key={year} value={year}>{year}</option>
))}
</select>
{selectedPeriod === 'month' && (
<select
value={selectedMonth}
onChange={(e) => setSelectedMonth(Number(e.target.value))}
className={styles.select}
>
{availableMonths.map((month) => (
<option key={month.value} value={month.value}>{month.label}</option>
))}
</select>
)}
</div>
</div>
<StatisticsChart statistics={statistics} loading={loading} />
</section>
</div>
);
};
export default BillingDashboard;

View file

@ -0,0 +1,142 @@
/**
* Billing Transactions Page
*
* Zeigt die Transaktionshistorie für den Benutzer.
*/
import React, { useEffect, useState } from 'react';
import { useBilling, type BillingTransaction } from '../../hooks/useBilling';
import styles from './Billing.module.css';
// ============================================================================
// TRANSACTION ROW COMPONENT
// ============================================================================
interface TransactionRowProps {
transaction: BillingTransaction;
}
const TransactionRow: React.FC<TransactionRowProps> = ({ transaction }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleString('de-CH', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const getTypeClass = (type: string) => {
switch (type) {
case 'CREDIT': return styles.credit;
case 'DEBIT': return styles.debit;
case 'ADJUSTMENT': return styles.adjustment;
default: return '';
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'CREDIT': return 'Gutschrift';
case 'DEBIT': return 'Belastung';
case 'ADJUSTMENT': return 'Korrektur';
default: return type;
}
};
return (
<tr>
<td>{formatDate(transaction.createdAt)}</td>
<td>
<span className={`${styles.transactionType} ${getTypeClass(transaction.transactionType)}`}>
{getTypeLabel(transaction.transactionType)}
</span>
</td>
<td>{transaction.description}</td>
<td>{transaction.aicoreProvider || '-'}</td>
<td>{transaction.featureCode || '-'}</td>
<td style={{ textAlign: 'right' }}>
{transaction.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(transaction.amount)}
</td>
</tr>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingTransactions: React.FC = () => {
const { transactions, loading, loadTransactions } = useBilling();
const [limit, setLimit] = useState(50);
useEffect(() => {
loadTransactions(limit);
}, [limit, loadTransactions]);
const handleLoadMore = () => {
setLimit(prev => prev + 50);
};
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Transaktionen</h1>
<p className={styles.subtitle}>Übersicht aller Kontobewegungen</p>
</header>
<section className={styles.section}>
{loading && transactions.length === 0 ? (
<div className={styles.loadingPlaceholder}>Lade Transaktionen...</div>
) : transactions.length === 0 ? (
<div className={styles.noData}>Keine Transaktionen vorhanden</div>
) : (
<>
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Beschreibung</th>
<th>Anbieter</th>
<th>Feature</th>
<th style={{ textAlign: 'right' }}>Betrag</th>
</tr>
</thead>
<tbody>
{transactions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))}
</tbody>
</table>
</div>
{transactions.length >= limit && (
<div style={{ textAlign: 'center', marginTop: 'var(--spacing-md)' }}>
<button
className={`${styles.button} ${styles.buttonSecondary}`}
onClick={handleLoadMore}
disabled={loading}
>
{loading ? 'Laden...' : 'Mehr laden'}
</button>
</div>
)}
</>
)}
</section>
</div>
);
};
export default BillingTransactions;

View file

@ -0,0 +1,7 @@
/**
* Billing Pages Exports
*/
export { BillingDashboard } from './BillingDashboard';
export { BillingTransactions } from './BillingTransactions';
export { BillingAdmin } from './BillingAdmin';

View file

@ -15,6 +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 type { Message } from '../../components/UiComponents/Messages/MessagesTypes';
import api from '../../api';
import styles from './PlaygroundPage.module.css';
@ -92,6 +93,9 @@ export const PlaygroundPage: React.FC = () => {
// Prompts dropdown state
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
// AI Provider selection state
const [selectedProvider, setSelectedProvider] = useState<string>('');
// Load prompts on mount
useEffect(() => {
refetchPrompts();
@ -782,6 +786,11 @@ export const PlaygroundPage: React.FC = () => {
>
<FaPlus />
</button>
<ProviderSelect
value={selectedProvider}
onChange={setSelectedProvider}
showLabel={false}
/>
<VoiceLanguageSelect
value={voiceLanguage}
onChange={setVoiceLanguage}