/** * Billing Admin Page * * Admin-Seite für Billing-Verwaltung (SysAdmin only). * - Settings verwalten * - Guthaben aufladen * - Konten übersicht */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling'; import type { CheckoutCreateRequest } from '../../api/billingApi'; import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates'; import { useCurrentUser } from '../../hooks/useUsers'; import api from '../../api'; import { getUserDataCache } from '../../utils/userCache'; import styles from './Billing.module.css'; const _formatCurrency = (amount: number) => { return new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount); }; const _mandateDisplayLabel = (m: UserMandateRow): string => { if (m.label) return m.label; if (typeof m.name === 'object' && m.name) { const n = m.name as Record; return n.de || n.en || Object.values(n)[0] || m.id; } return (m.name as string) || m.id; }; // ============================================================================ // MANDATE SELECTOR // ============================================================================ interface MandateSelectorProps { mandates: UserMandateRow[]; loading: boolean; selectedMandateId: string | null; onSelect: (mandateId: string) => void; } const MandateSelector: React.FC = ({ mandates, loading, selectedMandateId, onSelect, }) => (
); // ============================================================================ // 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 === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE') as BillingSettings['billingModel'], defaultUserCredit: Number(settings?.defaultUserCredit ?? 0), warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10), 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 === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE', defaultUserCredit: Number(settings.defaultUserCredit ?? 0), warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10), notifyOnWarning: settings.notifyOnWarning ?? true, }); } }, [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[]; users: MandateUserSummary[]; onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise; } const CreditAdder: React.FC = ({ settings, accounts, users, onAddCredit }) => { const [selectedUserId, setSelectedUserId] = useState(''); const [amount, setAmount] = useState(''); const [description, setDescription] = useState('Manuelles Aufladen durch Admin'); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const isPrepayUser = settings?.billingModel === 'PREPAY_USER'; const accountsByUserId = accounts .filter(acc => acc.accountType === 'USER') .reduce((map, acc) => { if (acc.userId) map[acc.userId] = acc; return map; }, {} as Record); const _handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const numAmount = parseFloat(amount); if (!numAmount || numAmount <= 0) { setMessage({ type: 'error', text: 'Betrag muss positiv sein' }); return; } setSaving(true); setMessage(null); try { await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description); setMessage({ type: 'success', text: `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` }); setAmount(''); } catch (err: any) { setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' }); } finally { setSaving(false); } }; return (

Guthaben manuell aufladen

{message && (
{message.text}
)}
{isPrepayUser && (
)}
setAmount(e.target.value)} placeholder="z.B. 50" min="0.01" step="0.01" required />
setDescription(e.target.value)} placeholder="Beschreibung der Gutschrift" />
); }; // ============================================================================ // ACCOUNTS OVERVIEW // ============================================================================ interface AccountsOverviewProps { accounts: AccountSummary[]; users: MandateUserSummary[]; loading: boolean; } const AccountsOverview: React.FC = ({ accounts, users, loading }) => { const formatCurrency = (amount: number) => { return new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount); }; // Build a lookup map: userId -> display name const _userNameMap = useMemo(() => { const map = new Map(); for (const user of users) { const displayName = user.displayName || [user.firstName, user.lastName].filter(Boolean).join(' ') || user.username || user.id; map.set(user.id, displayName); } return map; }, [users]); 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: {_userNameMap.get(account.userId) || account.userId}} Guthaben: {formatCurrency(account.balance)} Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}
))}
); }; // ============================================================================ // MANDATE ADMIN — STRIPE TOP-UP (same URL as SysAdmin billing admin) // ============================================================================ interface MandateStripeTopUpProps { mandateId: string; createCheckout: ( checkoutRequest: CheckoutCreateRequest, targetMandateId?: string ) => Promise<{ redirectUrl?: string } | null>; } const MandateStripeTopUp: React.FC = ({ mandateId, createCheckout }) => { const [amount, setAmount] = useState(''); const [busy, setBusy] = useState(false); const [localMsg, setLocalMsg] = useState(null); const _handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const n = parseFloat(amount); if (!n || n <= 0) { setLocalMsg('Betrag muss positiv sein'); return; } setBusy(true); setLocalMsg(null); try { const currentUser = getUserDataCache(); const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete('success'); currentUrl.searchParams.delete('canceled'); currentUrl.searchParams.delete('session_id'); currentUrl.hash = ''; const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`; const result = await createCheckout( { userId: currentUser?.id, amount: n, returnUrl }, mandateId ); if (result?.redirectUrl) { window.location.href = result.redirectUrl; } } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Checkout fehlgeschlagen'; setLocalMsg(msg); setBusy(false); } }; return (

Guthaben via Stripe aufladen

Sie werden zu Stripe weitergeleitet. Nach erfolgreicher Zahlung kehren Sie hierher zurück.

{localMsg &&
{localMsg}
}
setAmount(e.target.value)} placeholder="z.B. 50" min="0.01" step="0.01" required />
); }; // ============================================================================ // MAIN COMPONENT // ============================================================================ export const BillingAdmin: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const { user: currentUser } = useCurrentUser(); const isSysAdmin = currentUser?.isSysAdmin === true; const [selectedMandateId, setSelectedMandateId] = useState(null); const [mandateList, setMandateList] = useState([]); const [mandatesLoading, setMandatesLoading] = useState(true); const { fetchMandates } = useUserMandates(); const { settings, accounts, users, loading, saveSettings, addCredit, loadAccounts, createCheckout, } = 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) throw new Error('Mandant nicht ausgewählt'); const result = await addCredit({ userId, amount, description }); if (!result) throw new Error('Gutschrift konnte nicht erstellt werden'); await loadAccounts(); return result; }, [selectedMandateId, addCredit, loadAccounts]); useEffect(() => { let cancelled = false; (async () => { setMandatesLoading(true); try { const data = await fetchMandates(); if (!cancelled) { setMandateList(Array.isArray(data) ? data : []); } } finally { if (!cancelled) setMandatesLoading(false); } })(); return () => { cancelled = true; }; }, [fetchMandates]); useEffect(() => { if (!isSysAdmin && mandateList.length === 1 && selectedMandateId === null) { setSelectedMandateId(mandateList[0].id); } }, [isSysAdmin, mandateList, selectedMandateId]); const [stripeReturnMessage, setStripeReturnMessage] = useState<{ type: 'success' | 'error'; text: string; } | null>(null); const successParam = searchParams.get('success'); const canceledParam = searchParams.get('canceled'); const sessionIdParam = searchParams.get('session_id'); useEffect(() => { let cancelled = false; const _confirmCheckoutIfNeeded = async () => { if (successParam !== 'true') { if (canceledParam === 'true' && !cancelled) { setStripeReturnMessage({ type: 'error', text: 'Zahlung abgebrochen.' }); } return; } if (!sessionIdParam) { if (!cancelled) { setStripeReturnMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.', }); } if (selectedMandateId) await loadAccounts(selectedMandateId); return; } try { await api.post('/api/billing/checkout/confirm', { sessionId: sessionIdParam }); if (!cancelled) { setStripeReturnMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wurde verbucht.', }); } } catch (err: unknown) { const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail; if (!cancelled) { setStripeReturnMessage({ type: 'error', text: detail || 'Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.', }); } } finally { if (selectedMandateId) await loadAccounts(selectedMandateId); } }; _confirmCheckoutIfNeeded(); return () => { cancelled = true; }; }, [successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]); const _clearStripeParams = useCallback(() => { searchParams.delete('success'); searchParams.delete('canceled'); searchParams.delete('session_id'); setSearchParams(searchParams, { replace: true }); setStripeReturnMessage(null); }, [searchParams, setSearchParams]); const showStripeForMandateAdmin = !isSysAdmin && !!selectedMandateId && !!settings; return (

Billing Administration

{isSysAdmin ? 'Verwaltung von Abrechnungseinstellungen und Guthaben' : 'Guthaben und Konten für Ihre Mandanten'}

{isSysAdmin && (

Mandanten-Übersicht (Balances & Transaktionen)

)}
{stripeReturnMessage && (
{stripeReturnMessage.text}
)}
{selectedMandateId && ( <> {isSysAdmin && ( )} {isSysAdmin && ( )} {showStripeForMandateAdmin && ( )} )} {!selectedMandateId && (
Bitte wählen Sie einen Mandanten aus.
)}
); }; export default BillingAdmin;