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