815 lines
28 KiB
TypeScript
815 lines
28 KiB
TypeScript
/**
|
|
* 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 { 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 { useApiRequest } from '../../hooks/useApi';
|
|
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
|
import { SubscriptionTab } from './SubscriptionTab';
|
|
import api from '../../api';
|
|
import { getUserDataCache } from '../../utils/userCache';
|
|
import styles from './Billing.module.css';
|
|
|
|
type AdminTabType = 'settings' | 'credit' | 'subscription' | 'transactions';
|
|
|
|
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<string, string>;
|
|
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<MandateSelectorProps> = ({
|
|
mandates,
|
|
loading,
|
|
selectedMandateId,
|
|
onSelect,
|
|
}) => (
|
|
<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}>
|
|
{_mandateDisplayLabel(mandate)}
|
|
</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 === '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 (
|
|
<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 BillingSettings['billingModel'] }))}
|
|
>
|
|
<option value="PREPAY_MANDATE">Prepaid (Mandant)</option>
|
|
<option value="PREPAY_USER">Prepaid (Benutzer)</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>
|
|
|
|
<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[];
|
|
users: MandateUserSummary[];
|
|
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<any>;
|
|
}
|
|
|
|
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
|
|
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
|
const [amount, setAmount] = useState<string>('');
|
|
const [description, setDescription] = useState<string>('Manuelle Buchung 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<string, AccountSummary>);
|
|
|
|
const _handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const numAmount = parseFloat(amount);
|
|
if (!numAmount || numAmount === 0) {
|
|
setMessage({ type: 'error', text: 'Betrag darf nicht null sein' });
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
setMessage(null);
|
|
|
|
try {
|
|
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
|
|
const label = numAmount > 0
|
|
? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.`
|
|
: `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`;
|
|
setMessage({ type: 'success', text: label });
|
|
setAmount('');
|
|
} catch (err: any) {
|
|
setMessage({ type: 'error', text: err.message || 'Fehler bei der Buchung' });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={styles.adminSection}>
|
|
<h3>Guthaben manuell verwalten</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</label>
|
|
<select
|
|
className={styles.select}
|
|
value={selectedUserId}
|
|
onChange={(e) => setSelectedUserId(e.target.value)}
|
|
required
|
|
>
|
|
<option value="">-- Benutzer wählen --</option>
|
|
{users.map((user) => {
|
|
const account = accountsByUserId[user.id];
|
|
const balanceInfo = account ? ` (${_formatCurrency(account.balance)})` : ' (kein Konto)';
|
|
return (
|
|
<option key={user.id} value={user.id}>
|
|
{user.displayName || user.username || user.id}{balanceInfo}
|
|
</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(e.target.value)}
|
|
placeholder="z.B. 50 oder -20"
|
|
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="Beschreibung der Gutschrift"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
|
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
|
|
>
|
|
{saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// ACCOUNTS OVERVIEW
|
|
// ============================================================================
|
|
|
|
interface AccountsOverviewProps {
|
|
accounts: AccountSummary[];
|
|
users: MandateUserSummary[];
|
|
loading: boolean;
|
|
}
|
|
|
|
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ 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<string, string>();
|
|
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 <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: {_userNameMap.get(account.userId) || account.userId}</span>}
|
|
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
|
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// 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<MandateStripeTopUpProps> = ({ mandateId, createCheckout }) => {
|
|
const [amount, setAmount] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [localMsg, setLocalMsg] = useState<string | null>(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 (
|
|
<div className={styles.adminSection}>
|
|
<h3>Guthaben via Stripe aufladen</h3>
|
|
<p style={{ fontSize: '13px', color: 'var(--text-secondary, #64748b)', marginTop: 0 }}>
|
|
Sie werden zu Stripe weitergeleitet. Nach erfolgreicher Zahlung kehren Sie hierher zurück.
|
|
</p>
|
|
{localMsg && <div className={styles.errorMessage}>{localMsg}</div>}
|
|
<form onSubmit={_handleSubmit}>
|
|
<div className={styles.formRow}>
|
|
<div className={styles.formGroup}>
|
|
<label>Betrag (CHF)</label>
|
|
<input
|
|
type="number"
|
|
className={styles.input}
|
|
value={amount}
|
|
onChange={e => setAmount(e.target.value)}
|
|
placeholder="z.B. 50"
|
|
min="0.01"
|
|
step="0.01"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
|
disabled={busy || !amount}
|
|
>
|
|
{busy ? 'Weiterleitung...' : 'Mit Stripe bezahlen'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// MANDATE TRANSACTIONS TAB (FormGeneratorTable with filters, search, export)
|
|
// ============================================================================
|
|
|
|
const _mandateTxColumns: ColumnConfig[] = [
|
|
{ key: 'createdAt', label: 'Datum', type: 'timestamp' as any, sortable: true, width: 160 },
|
|
{ key: 'userName', label: 'Benutzer', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
|
|
{ key: 'transactionType', label: 'Typ', type: 'text' as any, sortable: true, filterable: true, width: 100 },
|
|
{ key: 'description', label: 'Beschreibung', type: 'text' as any, searchable: true, width: 250 },
|
|
{ key: 'aicoreProvider', label: 'Anbieter', type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
|
{ key: 'aicoreModel', label: 'Modell', type: 'text' as any, sortable: true, filterable: true, width: 150 },
|
|
{ key: 'featureCode', label: 'Feature', type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
|
{ key: 'amount', label: 'Betrag (CHF)', type: 'number' as any, sortable: true, width: 120 },
|
|
];
|
|
|
|
interface MandateTransactionsTabProps {
|
|
mandateId: string;
|
|
}
|
|
|
|
const MandateTransactionsTab: React.FC<MandateTransactionsTabProps> = ({ mandateId }) => {
|
|
const { request, isLoading: loading } = useApiRequest();
|
|
const [transactions, setTransactions] = useState<any[]>([]);
|
|
const [pagination, setPagination] = useState<any>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const _loadTransactions = useCallback(async (params?: any) => {
|
|
try {
|
|
setError(null);
|
|
const requestParams: Record<string, string> = {};
|
|
if (params) {
|
|
const paginationObj: any = {};
|
|
if (params.page !== undefined) paginationObj.page = params.page;
|
|
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
|
if (params.sort) paginationObj.sort = params.sort;
|
|
if (params.filters) paginationObj.filters = params.filters;
|
|
if (params.search) paginationObj.search = params.search;
|
|
if (Object.keys(paginationObj).length > 0) {
|
|
requestParams.pagination = JSON.stringify(paginationObj);
|
|
}
|
|
}
|
|
const data = await request({
|
|
url: `/api/billing/admin/transactions/${mandateId}`,
|
|
method: 'get',
|
|
params: requestParams,
|
|
});
|
|
if (data && typeof data === 'object' && 'items' in data) {
|
|
setTransactions(Array.isArray(data.items) ? data.items : []);
|
|
if (data.pagination) setPagination(data.pagination);
|
|
} else {
|
|
setTransactions(Array.isArray(data) ? data : []);
|
|
setPagination(null);
|
|
}
|
|
} catch (err: any) {
|
|
setError(err?.response?.data?.detail || err.message || 'Fehler beim Laden');
|
|
setTransactions([]);
|
|
setPagination(null);
|
|
}
|
|
}, [request, mandateId]);
|
|
|
|
useEffect(() => {
|
|
_loadTransactions();
|
|
}, [_loadTransactions]);
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: '400px' }}>
|
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: '0 0 1rem 0' }}>
|
|
AI-Verbrauch und Guthaben-Transaktionen. Subscription-Gebühren werden separat über Stripe abgerechnet.
|
|
</p>
|
|
{error && <div className={styles.errorMessage}>{error}</div>}
|
|
<FormGeneratorTable
|
|
data={transactions}
|
|
columns={_mandateTxColumns}
|
|
apiEndpoint={`/api/billing/admin/transactions/${mandateId}`}
|
|
loading={loading}
|
|
pagination={true}
|
|
pageSize={25}
|
|
searchable={true}
|
|
filterable={true}
|
|
sortable={true}
|
|
selectable={false}
|
|
emptyMessage="Keine Transaktionen für diesen Mandanten"
|
|
onRefresh={() => _loadTransactions()}
|
|
hookData={{ refetch: _loadTransactions, pagination }}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// MAIN COMPONENT
|
|
// ============================================================================
|
|
|
|
export const BillingAdmin: React.FC = () => {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const { user: currentUser } = useCurrentUser();
|
|
const isSysAdmin = currentUser?.isSysAdmin === true;
|
|
|
|
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(
|
|
searchParams.get('mandate') || null
|
|
);
|
|
const [mandateList, setMandateList] = useState<UserMandateRow[]>([]);
|
|
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<BillingSettings>) => {
|
|
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');
|
|
|
|
const _initialAdminTab = (searchParams.get('tab') as AdminTabType) || 'settings';
|
|
const [adminTab, setAdminTab] = useState<AdminTabType>(
|
|
['settings', 'credit', 'subscription', 'transactions'].includes(_initialAdminTab) ? _initialAdminTab : 'settings'
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (adminTab === 'subscription' || searchParams.get('tab') === 'subscription') return;
|
|
|
|
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;
|
|
};
|
|
}, [adminTab, successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]);
|
|
|
|
const _clearStripeParams = useCallback(() => {
|
|
searchParams.delete('success');
|
|
searchParams.delete('canceled');
|
|
searchParams.delete('session_id');
|
|
searchParams.delete('mandate');
|
|
setSearchParams(searchParams, { replace: true });
|
|
setStripeReturnMessage(null);
|
|
}, [searchParams, setSearchParams]);
|
|
|
|
const showStripeForMandateAdmin = !!selectedMandateId && !!settings;
|
|
|
|
const _tabStyle = (isActive: boolean) => ({
|
|
padding: '8px 16px',
|
|
textDecoration: 'none',
|
|
borderRadius: '4px',
|
|
backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent',
|
|
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
|
|
fontWeight: isActive ? 600 : 400,
|
|
cursor: 'pointer',
|
|
border: 'none',
|
|
fontSize: '14px',
|
|
});
|
|
|
|
return (
|
|
<div className={styles.billingDashboard}>
|
|
<header className={styles.pageHeader}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
|
<div>
|
|
<h1>Billing-Verwaltung</h1>
|
|
<p className={styles.subtitle}>
|
|
Abrechnungseinstellungen, Guthaben und Abonnement pro Mandant
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{stripeReturnMessage && (
|
|
<div
|
|
className={
|
|
stripeReturnMessage.type === 'success' ? styles.successMessage : styles.errorMessage
|
|
}
|
|
style={{ marginBottom: '1rem' }}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '12px', alignItems: 'center' }}>
|
|
<span>{stripeReturnMessage.text}</span>
|
|
<button type="button" className={styles.button} onClick={_clearStripeParams}>
|
|
OK
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<section className={styles.section}>
|
|
<MandateSelector
|
|
mandates={mandateList}
|
|
loading={mandatesLoading}
|
|
selectedMandateId={selectedMandateId}
|
|
onSelect={handleMandateSelect}
|
|
/>
|
|
</section>
|
|
|
|
{selectedMandateId ? (
|
|
<>
|
|
<nav style={{
|
|
display: 'flex',
|
|
gap: '8px',
|
|
marginBottom: '24px',
|
|
borderBottom: '1px solid var(--color-border, #333)',
|
|
paddingBottom: '8px',
|
|
}}>
|
|
<button onClick={() => setAdminTab('settings')} style={_tabStyle(adminTab === 'settings')}>
|
|
Einstellungen
|
|
</button>
|
|
<button onClick={() => setAdminTab('credit')} style={_tabStyle(adminTab === 'credit')}>
|
|
Guthaben
|
|
</button>
|
|
<button onClick={() => setAdminTab('subscription')} style={_tabStyle(adminTab === 'subscription')}>
|
|
Abonnement
|
|
</button>
|
|
<button onClick={() => setAdminTab('transactions')} style={_tabStyle(adminTab === 'transactions')}>
|
|
Transaktionen
|
|
</button>
|
|
</nav>
|
|
|
|
{adminTab === 'settings' && (
|
|
<SettingsEditor
|
|
settings={settings}
|
|
onSave={handleSaveSettings}
|
|
loading={loading}
|
|
/>
|
|
)}
|
|
|
|
{adminTab === 'credit' && (
|
|
<>
|
|
{isSysAdmin && (
|
|
<CreditAdder
|
|
settings={settings}
|
|
accounts={accounts}
|
|
users={users}
|
|
onAddCredit={_handleAddCredit}
|
|
/>
|
|
)}
|
|
|
|
{showStripeForMandateAdmin && (
|
|
<MandateStripeTopUp mandateId={selectedMandateId} createCheckout={createCheckout} />
|
|
)}
|
|
|
|
<AccountsOverview accounts={accounts} users={users} loading={loading} />
|
|
</>
|
|
)}
|
|
|
|
{adminTab === 'subscription' && (
|
|
<SubscriptionTab mandateId={selectedMandateId} />
|
|
)}
|
|
|
|
{adminTab === 'transactions' && (
|
|
<MandateTransactionsTab mandateId={selectedMandateId} />
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className={styles.noData}>Bitte wählen Sie einen Mandanten aus.</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BillingAdmin;
|