From 919f6e4b7da61082757e59c9fdb9a2421ba7f254 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 4 Feb 2026 21:51:20 +0100 Subject: [PATCH] billing initial --- src/App.tsx | 12 + src/api/automationApi.ts | 1 + src/api/billingApi.ts | 254 +++++++++ .../AutomationEditor/AutomationEditor.tsx | 20 +- .../ProviderSelector.module.css | 161 ++++++ .../ProviderSelector/ProviderSelector.tsx | 221 ++++++++ src/components/ProviderSelector/index.ts | 10 + src/config/pageRegistry.tsx | 7 +- src/hooks/useBilling.ts | 265 +++++++++ src/pages/billing/Billing.module.css | 518 ++++++++++++++++++ src/pages/billing/BillingAdmin.tsx | 419 ++++++++++++++ src/pages/billing/BillingDashboard.tsx | 247 +++++++++ src/pages/billing/BillingTransactions.tsx | 142 +++++ src/pages/billing/index.ts | 7 + src/pages/workflows/PlaygroundPage.tsx | 9 + 15 files changed, 2290 insertions(+), 3 deletions(-) create mode 100644 src/api/billingApi.ts create mode 100644 src/components/ProviderSelector/ProviderSelector.module.css create mode 100644 src/components/ProviderSelector/ProviderSelector.tsx create mode 100644 src/components/ProviderSelector/index.ts create mode 100644 src/hooks/useBilling.ts create mode 100644 src/pages/billing/Billing.module.css create mode 100644 src/pages/billing/BillingAdmin.tsx create mode 100644 src/pages/billing/BillingDashboard.tsx create mode 100644 src/pages/billing/BillingTransactions.tsx create mode 100644 src/pages/billing/index.ts diff --git a/src/App.tsx b/src/App.tsx index 0a7591b..41c6028 100644 --- a/src/App.tsx +++ b/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() { } /> + {/* ============================================== */} + {/* BILLING ROUTES */} + {/* ============================================== */} + + } /> + } /> + + {/* ============================================== */} {/* FEATURE-INSTANZ ROUTES */} {/* /mandates/:mandateId/:featureCode/:instanceId */} @@ -165,6 +176,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/api/automationApi.ts b/src/api/automationApi.ts index b6f0628..296933b 100644 --- a/src/api/automationApi.ts +++ b/src/api/automationApi.ts @@ -17,6 +17,7 @@ export interface Automation { lastExecution?: number; nextExecution?: number; executionLogs?: AutomationLog[]; + allowedProviders?: string[]; _createdAt?: number; _updatedAt?: number; _createdByUserName?: string; diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts new file mode 100644 index 0000000..c43c40f --- /dev/null +++ b/src/api/billingApi.ts @@ -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; + costByFeature: Record; +} + +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) => Promise; + +// ============================================================================ +// 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 { + 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 { + 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 { + 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 { + const params: Record = { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return await request({ + url: `/api/billing/admin/transactions/${mandateId}`, + method: 'get', + params: { limit } + }); +} diff --git a/src/components/AutomationEditor/AutomationEditor.tsx b/src/components/AutomationEditor/AutomationEditor.tsx index 677daea..255e404 100644 --- a/src/components/AutomationEditor/AutomationEditor.tsx +++ b/src/components/AutomationEditor/AutomationEditor.tsx @@ -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 = ({ const [label, setLabel] = useState(''); const [schedule, setSchedule] = useState('0 22 * * *'); const [active, setActive] = useState(false); + const [allowedProviders, setAllowedProviders] = useState([]); // Template multilingual fields const [labelMulti, setLabelMulti] = useState({ en: '', de: '' }); @@ -530,6 +532,7 @@ export const AutomationEditor: React.FC = ({ 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 = ({ schedule, active, template: templateJson, - placeholders + placeholders, + allowedProviders }; } @@ -700,7 +704,7 @@ export const AutomationEditor: React.FC = ({ } 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 = ({ Automatisierung ist aktiv und wird planmässig ausgeführt

+ + {/* Allowed AI Providers */} +
+ +

+ Beschränkt die Automation auf bestimmte AI-Provider. Leer = alle erlaubt. +

+
)} diff --git a/src/components/ProviderSelector/ProviderSelector.module.css b/src/components/ProviderSelector/ProviderSelector.module.css new file mode 100644 index 0000000..465ed1e --- /dev/null +++ b/src/components/ProviderSelector/ProviderSelector.module.css @@ -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; +} diff --git a/src/components/ProviderSelector/ProviderSelector.tsx b/src/components/ProviderSelector/ProviderSelector.tsx new file mode 100644 index 0000000..fb36ea5 --- /dev/null +++ b/src/components/ProviderSelector/ProviderSelector.tsx @@ -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 = { + anthropic: 'Anthropic (Claude)', + openai: 'OpenAI (GPT)', + perplexity: 'Perplexity', + tavily: 'Tavily (Web Search)', + internal: 'Internal', +}; + +// Provider icons (emojis for simplicity) +const PROVIDER_ICONS: Record = { + 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 = ({ + 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 ( +
+ {showLabel && } + +
+ ); +}; + +// ============================================================================ +// 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 = ({ + 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 ( +
+ {showLabel && } + +
+ + +
+ + {loading ? ( +
Lade Provider...
+ ) : ( +
+ {allowedProviders.map((provider) => ( + + ))} +
+ )} + + {selectedProviders.length === 0 && !loading && ( +
+ Wenn keine Provider ausgewählt sind, werden alle erlaubten Provider verwendet. +
+ )} +
+ ); +}; + +// ============================================================================ +// COMPACT PROVIDER BADGE LIST +// ============================================================================ + +interface ProviderBadgesProps { + providers: string[]; + className?: string; +} + +export const ProviderBadges: React.FC = ({ + providers, + className, +}) => { + if (providers.length === 0) { + return Alle Provider; + } + + return ( +
+ {providers.map((provider) => ( + + {PROVIDER_ICONS[provider] || '🔌'} {PROVIDER_LABELS[provider] || provider} + + ))} +
+ ); +}; + +// Default export +export default ProviderSelect; diff --git a/src/components/ProviderSelector/index.ts b/src/components/ProviderSelector/index.ts new file mode 100644 index 0000000..afe1b42 --- /dev/null +++ b/src/components/ProviderSelector/index.ts @@ -0,0 +1,10 @@ +/** + * Provider Selector Component Exports + */ + +export { + ProviderSelect, + ProviderMultiSelect, + ProviderBadges +} from './ProviderSelector'; +export { default } from './ProviderSelector'; diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 62f8828..35785cc 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -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 = { 'page.system.pek': , 'page.system.speech': , + // Billing pages + 'page.billing.dashboard': , + 'page.billing.transactions': , + // Admin pages 'page.admin.access': , 'page.admin.users': , @@ -59,6 +63,7 @@ export const PAGE_ICONS: Record = { 'page.admin.feature-instances': , 'page.admin.feature-users': , 'page.admin.user-access-overview': , + 'page.admin.billing': , // Feature pages - Trustee 'page.feature.trustee.dashboard': , diff --git a/src/hooks/useBilling.ts b/src/hooks/useBilling.ts new file mode 100644 index 0000000..70298aa --- /dev/null +++ b/src/hooks/useBilling.ts @@ -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([]); + const [transactions, setTransactions] = useState([]); + const [statistics, setStatistics] = useState(null); + const [allowedProviders, setAllowedProviders] = useState([]); + 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(null); + const [accounts, setAccounts] = useState([]); + const [transactions, setTransactions] = useState([]); + 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; diff --git a/src/pages/billing/Billing.module.css b/src/pages/billing/Billing.module.css new file mode 100644 index 0000000..f7c7304 --- /dev/null +++ b/src/pages/billing/Billing.module.css @@ -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); + } +} diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx new file mode 100644 index 0000000..fc16ac7 --- /dev/null +++ b/src/pages/billing/BillingAdmin.tsx @@ -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 = ({ selectedMandateId, onSelect }) => { + const { mandates, loading } = useAdminMandates(); + + return ( +
+ + +
+ ); +}; + +// ============================================================================ +// SETTINGS EDITOR +// ============================================================================ + +interface SettingsEditorProps { + settings: BillingSettings | null; + onSave: (settings: Partial) => Promise; + loading: boolean; +} + +const SettingsEditor: React.FC = ({ 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 ( +
+

Billing-Einstellungen

+ + {message && ( +
+ {message.text} +
+ )} + +
+
+
+ + +
+ +
+ + setFormData(prev => ({ ...prev, defaultUserCredit: Number(e.target.value) }))} + min="0" + step="0.01" + /> +
+
+ +
+
+ + setFormData(prev => ({ ...prev, warningThresholdPercent: Number(e.target.value) }))} + min="0" + max="100" + step="1" + /> +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ ); +}; + +// ============================================================================ +// CREDIT ADDER +// ============================================================================ + +interface CreditAdderProps { + settings: BillingSettings | null; + accounts: AccountSummary[]; + onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise; +} + +const CreditAdder: React.FC = ({ settings, accounts, onAddCredit }) => { + const [selectedUserId, setSelectedUserId] = useState(''); + const [amount, setAmount] = useState(10); + const [description, setDescription] = useState('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 ( +
+

Guthaben aufladen

+ + {message && ( +
+ {message.text} +
+ )} + +
+ {isPrepayUser && ( +
+
+ + +
+
+ )} + +
+
+ + setAmount(Number(e.target.value))} + min="0.01" + step="0.01" + required + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="Grund für Gutschrift" + /> +
+
+ + +
+
+ ); +}; + +// ============================================================================ +// ACCOUNTS OVERVIEW +// ============================================================================ + +interface AccountsOverviewProps { + accounts: AccountSummary[]; + loading: boolean; +} + +const AccountsOverview: React.FC = ({ accounts, loading }) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + if (loading) { + return
Lade Konten...
; + } + + if (accounts.length === 0) { + return
Keine Konten vorhanden
; + } + + return ( +
+

Konten

+
+ {accounts.map((account) => ( +
+

{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}

+
+ {account.userId && User: {account.userId}} + Guthaben: {formatCurrency(account.balance)} + {account.creditLimit && Limit: {formatCurrency(account.creditLimit)}} + Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'} +
+
+ ))} +
+
+ ); +}; + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +export const BillingAdmin: React.FC = () => { + const [selectedMandateId, setSelectedMandateId] = useState(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) => { + 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 ( +
+
+

Billing Administration

+

Verwaltung von Abrechnungseinstellungen und Guthaben

+
+ +
+ +
+ + {selectedMandateId && ( + <> + + + + + + + )} + + {!selectedMandateId && ( +
+ Bitte wählen Sie einen Mandanten aus. +
+ )} +
+ ); +}; + +export default BillingAdmin; diff --git a/src/pages/billing/BillingDashboard.tsx b/src/pages/billing/BillingDashboard.tsx new file mode 100644 index 0000000..2576818 --- /dev/null +++ b/src/pages/billing/BillingDashboard.tsx @@ -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 = ({ 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 ( +
+
+

{balance.mandateName}

+ {getBillingModelLabel(balance.billingModel)} +
+
+ {formatCurrency(balance.balance)} +
+ {balance.isWarning && ( +
+ Niedriges Guthaben +
+ )} +
+ ); +}; + +// ============================================================================ +// STATISTICS CHART COMPONENT +// ============================================================================ + +interface StatisticsChartProps { + statistics: UsageReport | null; + loading?: boolean; +} + +const StatisticsChart: React.FC = ({ statistics, loading }) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('de-CH', { + style: 'currency', + currency: 'CHF' + }).format(amount); + }; + + if (loading) { + return
Lade Statistiken...
; + } + + if (!statistics) { + return
Keine Statistiken verfügbar
; + } + + // Calculate max cost for bar scaling + const maxProviderCost = Math.max(...Object.values(statistics.costByProvider), 1); + + return ( +
+
+ Gesamtkosten + {formatCurrency(statistics.totalCost)} +
+ +
+

Kosten nach Anbieter

+ {Object.entries(statistics.costByProvider).length === 0 ? ( +
Keine Daten
+ ) : ( +
+ {Object.entries(statistics.costByProvider).map(([provider, cost]) => ( +
+ {provider} +
+
+
+ {formatCurrency(cost)} +
+ ))} +
+ )} +
+ +
+

Kosten nach Feature

+ {Object.entries(statistics.costByFeature).length === 0 ? ( +
Keine Daten
+ ) : ( +
+ {Object.entries(statistics.costByFeature).map(([feature, cost]) => ( +
+ {feature} + {formatCurrency(cost)} +
+ ))} +
+ )} +
+
+ ); +}; + +// ============================================================================ +// 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 ( +
+
+

Billing

+

Übersicht über Guthaben und Nutzung

+
+ + {/* Balance Cards */} +
+

Guthaben

+ {loading ? ( +
Lade Guthaben...
+ ) : balances.length === 0 ? ( +
Keine Abrechnungskonten vorhanden
+ ) : ( +
+ {balances.map((balance) => ( + + ))} +
+ )} +
+ + {/* Statistics */} +
+
+

Nutzungsstatistik

+
+ + + {selectedPeriod === 'month' && ( + + )} +
+
+ +
+
+ ); +}; + +export default BillingDashboard; diff --git a/src/pages/billing/BillingTransactions.tsx b/src/pages/billing/BillingTransactions.tsx new file mode 100644 index 0000000..f584bce --- /dev/null +++ b/src/pages/billing/BillingTransactions.tsx @@ -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 = ({ 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 ( + + {formatDate(transaction.createdAt)} + + + {getTypeLabel(transaction.transactionType)} + + + {transaction.description} + {transaction.aicoreProvider || '-'} + {transaction.featureCode || '-'} + + {transaction.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(transaction.amount)} + + + ); +}; + +// ============================================================================ +// 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 ( +
+
+

Transaktionen

+

Übersicht aller Kontobewegungen

+
+ +
+ {loading && transactions.length === 0 ? ( +
Lade Transaktionen...
+ ) : transactions.length === 0 ? ( +
Keine Transaktionen vorhanden
+ ) : ( + <> +
+ + + + + + + + + + + + + {transactions.map((transaction) => ( + + ))} + +
DatumTypBeschreibungAnbieterFeatureBetrag
+
+ + {transactions.length >= limit && ( +
+ +
+ )} + + )} +
+
+ ); +}; + +export default BillingTransactions; diff --git a/src/pages/billing/index.ts b/src/pages/billing/index.ts new file mode 100644 index 0000000..eb4f6c7 --- /dev/null +++ b/src/pages/billing/index.ts @@ -0,0 +1,7 @@ +/** + * Billing Pages Exports + */ + +export { BillingDashboard } from './BillingDashboard'; +export { BillingTransactions } from './BillingTransactions'; +export { BillingAdmin } from './BillingAdmin'; diff --git a/src/pages/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx index ba20467..f85a71b 100644 --- a/src/pages/workflows/PlaygroundPage.tsx +++ b/src/pages/workflows/PlaygroundPage.tsx @@ -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(''); + // AI Provider selection state + const [selectedProvider, setSelectedProvider] = useState(''); + // Load prompts on mount useEffect(() => { refetchPrompts(); @@ -782,6 +786,11 @@ export const PlaygroundPage: React.FC = () => { > +