billing initial
This commit is contained in:
parent
ff1caba925
commit
919f6e4b7d
15 changed files with 2290 additions and 3 deletions
12
src/App.tsx
12
src/App.tsx
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
254
src/api/billingApi.ts
Normal 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 }
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
161
src/components/ProviderSelector/ProviderSelector.module.css
Normal file
161
src/components/ProviderSelector/ProviderSelector.module.css
Normal 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;
|
||||
}
|
||||
221
src/components/ProviderSelector/ProviderSelector.tsx
Normal file
221
src/components/ProviderSelector/ProviderSelector.tsx
Normal 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;
|
||||
10
src/components/ProviderSelector/index.ts
Normal file
10
src/components/ProviderSelector/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Provider Selector Component Exports
|
||||
*/
|
||||
|
||||
export {
|
||||
ProviderSelect,
|
||||
ProviderMultiSelect,
|
||||
ProviderBadges
|
||||
} from './ProviderSelector';
|
||||
export { default } from './ProviderSelector';
|
||||
|
|
@ -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
265
src/hooks/useBilling.ts
Normal 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;
|
||||
518
src/pages/billing/Billing.module.css
Normal file
518
src/pages/billing/Billing.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
419
src/pages/billing/BillingAdmin.tsx
Normal file
419
src/pages/billing/BillingAdmin.tsx
Normal 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> </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> </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;
|
||||
247
src/pages/billing/BillingDashboard.tsx
Normal file
247
src/pages/billing/BillingDashboard.tsx
Normal 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;
|
||||
142
src/pages/billing/BillingTransactions.tsx
Normal file
142
src/pages/billing/BillingTransactions.tsx
Normal 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;
|
||||
7
src/pages/billing/index.ts
Normal file
7
src/pages/billing/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Billing Pages Exports
|
||||
*/
|
||||
|
||||
export { BillingDashboard } from './BillingDashboard';
|
||||
export { BillingTransactions } from './BillingTransactions';
|
||||
export { BillingAdmin } from './BillingAdmin';
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue