378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
/**
|
|
* Billing User View Page
|
|
*
|
|
* Shows user-level balances and transactions.
|
|
* RBAC-based: Users see only their own data, Admins see all.
|
|
* Includes filtering by mandate and user.
|
|
*/
|
|
|
|
import React, { useEffect, useState, useMemo } from 'react';
|
|
import { useApiRequest } from '../../hooks/useApi';
|
|
import {
|
|
fetchUserViewBalances,
|
|
fetchUserViewTransactions,
|
|
type UserBalance,
|
|
type UserTransaction
|
|
} from '../../api/billingApi';
|
|
import { BillingNav } from './BillingNav';
|
|
import styles from './Billing.module.css';
|
|
|
|
// ============================================================================
|
|
// USER BALANCE TABLE
|
|
// ============================================================================
|
|
|
|
interface UserBalanceTableProps {
|
|
balances: UserBalance[];
|
|
selectedMandateId: string | null;
|
|
selectedUserId: string | null;
|
|
onSelectMandate: (mandateId: string | null) => void;
|
|
onSelectUser: (userId: string | null) => void;
|
|
}
|
|
|
|
const UserBalanceTable: React.FC<UserBalanceTableProps> = ({
|
|
balances,
|
|
selectedMandateId,
|
|
selectedUserId,
|
|
onSelectMandate,
|
|
onSelectUser
|
|
}) => {
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat('de-CH', {
|
|
style: 'currency',
|
|
currency: 'CHF'
|
|
}).format(amount);
|
|
};
|
|
|
|
// Get unique mandates and users for filter dropdowns
|
|
const uniqueMandates = useMemo(() => {
|
|
const mandates = new Map<string, string>();
|
|
balances.forEach(b => {
|
|
if (b.mandateId && !mandates.has(b.mandateId)) {
|
|
mandates.set(b.mandateId, b.mandateName || b.mandateId);
|
|
}
|
|
});
|
|
return Array.from(mandates.entries());
|
|
}, [balances]);
|
|
|
|
const uniqueUsers = useMemo(() => {
|
|
const users = new Map<string, string>();
|
|
balances.forEach(b => {
|
|
if (b.userId && !users.has(b.userId)) {
|
|
users.set(b.userId, b.userName || b.userId);
|
|
}
|
|
});
|
|
return Array.from(users.entries());
|
|
}, [balances]);
|
|
|
|
// Apply filters
|
|
const filteredBalances = useMemo(() => {
|
|
let result = balances;
|
|
if (selectedMandateId) {
|
|
result = result.filter(b => b.mandateId === selectedMandateId);
|
|
}
|
|
if (selectedUserId) {
|
|
result = result.filter(b => b.userId === selectedUserId);
|
|
}
|
|
return result;
|
|
}, [balances, selectedMandateId, selectedUserId]);
|
|
|
|
return (
|
|
<>
|
|
{/* Filter Controls */}
|
|
<div className={styles.filterControls} style={{ display: 'flex', gap: '16px', marginBottom: '16px' }}>
|
|
<div>
|
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', fontWeight: 500 }}>
|
|
Mandant:
|
|
</label>
|
|
<select
|
|
value={selectedMandateId || ''}
|
|
onChange={(e) => onSelectMandate(e.target.value || null)}
|
|
className={styles.select}
|
|
>
|
|
<option value="">Alle Mandanten</option>
|
|
{uniqueMandates.map(([id, name]) => (
|
|
<option key={id} value={id}>{name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', marginBottom: '4px', fontSize: '12px', fontWeight: 500 }}>
|
|
Benutzer:
|
|
</label>
|
|
<select
|
|
value={selectedUserId || ''}
|
|
onChange={(e) => onSelectUser(e.target.value || null)}
|
|
className={styles.select}
|
|
>
|
|
<option value="">Alle Benutzer</option>
|
|
{uniqueUsers.map(([id, name]) => (
|
|
<option key={id} value={id}>{name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table className={styles.transactionsTable}>
|
|
<thead>
|
|
<tr>
|
|
<th>Mandant</th>
|
|
<th>Benutzer</th>
|
|
<th style={{ textAlign: 'right' }}>Guthaben</th>
|
|
<th style={{ textAlign: 'right' }}>Warnschwelle</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredBalances.map((balance) => (
|
|
<tr
|
|
key={balance.accountId}
|
|
className={balance.isWarning ? styles.warningRow : ''}
|
|
>
|
|
<td>{balance.mandateName || balance.mandateId}</td>
|
|
<td>{balance.userName || balance.userId}</td>
|
|
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.balance)}</td>
|
|
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.warningThreshold)}</td>
|
|
<td>
|
|
{balance.isWarning ? (
|
|
<span className={styles.warningBadge} style={{ fontSize: '12px', padding: '2px 6px' }}>
|
|
Niedrig
|
|
</span>
|
|
) : balance.enabled ? (
|
|
<span style={{ color: 'var(--color-success)' }}>Aktiv</span>
|
|
) : (
|
|
<span style={{ color: 'var(--color-error)' }}>Deaktiviert</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// USER TRANSACTION TABLE
|
|
// ============================================================================
|
|
|
|
interface UserTransactionTableProps {
|
|
transactions: UserTransaction[];
|
|
selectedMandateId: string | null;
|
|
selectedUserId: string | null;
|
|
}
|
|
|
|
const UserTransactionTable: React.FC<UserTransactionTableProps> = ({
|
|
transactions,
|
|
selectedMandateId,
|
|
selectedUserId
|
|
}) => {
|
|
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;
|
|
}
|
|
};
|
|
|
|
// Apply filters
|
|
const filteredTransactions = useMemo(() => {
|
|
let result = transactions;
|
|
if (selectedMandateId) {
|
|
result = result.filter(t => t.mandateId === selectedMandateId);
|
|
}
|
|
if (selectedUserId) {
|
|
result = result.filter(t => t.userId === selectedUserId);
|
|
}
|
|
return result;
|
|
}, [transactions, selectedMandateId, selectedUserId]);
|
|
|
|
return (
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table className={styles.transactionsTable}>
|
|
<thead>
|
|
<tr>
|
|
<th>Datum</th>
|
|
<th>Mandant</th>
|
|
<th>Benutzer</th>
|
|
<th>Typ</th>
|
|
<th>Beschreibung</th>
|
|
<th>Anbieter</th>
|
|
<th>Modell</th>
|
|
<th>Feature</th>
|
|
<th style={{ textAlign: 'right' }}>Betrag</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredTransactions.map((t) => (
|
|
<tr key={t.id}>
|
|
<td>{formatDate(t.createdAt)}</td>
|
|
<td>{t.mandateName || '-'}</td>
|
|
<td>{t.userName || '-'}</td>
|
|
<td>
|
|
<span className={`${styles.transactionType} ${getTypeClass(t.transactionType)}`}>
|
|
{getTypeLabel(t.transactionType)}
|
|
</span>
|
|
</td>
|
|
<td>{t.description}</td>
|
|
<td>{t.aicoreProvider || '-'}</td>
|
|
<td>{t.aicoreModel || '-'}</td>
|
|
<td>{t.featureCode || '-'}</td>
|
|
<td style={{ textAlign: 'right' }}>
|
|
{t.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(t.amount)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ============================================================================
|
|
// MAIN COMPONENT
|
|
// ============================================================================
|
|
|
|
export const BillingUserView: React.FC = () => {
|
|
const { request, isLoading: loading } = useApiRequest();
|
|
const [balances, setBalances] = useState<UserBalance[]>([]);
|
|
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
|
|
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
|
const [limit, setLimit] = useState(100);
|
|
|
|
// Load data
|
|
useEffect(() => {
|
|
const loadData = async () => {
|
|
try {
|
|
const [balanceData, transactionData] = await Promise.all([
|
|
fetchUserViewBalances(request),
|
|
fetchUserViewTransactions(request, limit)
|
|
]);
|
|
setBalances(Array.isArray(balanceData) ? balanceData : []);
|
|
setTransactions(Array.isArray(transactionData) ? transactionData : []);
|
|
} catch (err) {
|
|
console.error('Error loading user view data:', err);
|
|
setBalances([]);
|
|
setTransactions([]);
|
|
}
|
|
};
|
|
loadData();
|
|
}, [request, limit]);
|
|
|
|
const handleLoadMore = () => {
|
|
setLimit(prev => prev + 100);
|
|
};
|
|
|
|
// Count filtered transactions for display
|
|
const filteredTransactionCount = useMemo(() => {
|
|
let result = transactions;
|
|
if (selectedMandateId) {
|
|
result = result.filter(t => t.mandateId === selectedMandateId);
|
|
}
|
|
if (selectedUserId) {
|
|
result = result.filter(t => t.userId === selectedUserId);
|
|
}
|
|
return result.length;
|
|
}, [transactions, selectedMandateId, selectedUserId]);
|
|
|
|
return (
|
|
<div className={styles.billingDashboard}>
|
|
<header className={styles.pageHeader}>
|
|
<h1>Benutzer-Billing</h1>
|
|
<p className={styles.subtitle}>Guthaben und Transaktionen pro Benutzer</p>
|
|
</header>
|
|
|
|
<BillingNav />
|
|
|
|
{/* User Balances */}
|
|
<section className={styles.section}>
|
|
<h2 className={styles.sectionTitle}>Benutzer-Guthaben</h2>
|
|
{loading && balances.length === 0 ? (
|
|
<div className={styles.loadingPlaceholder}>Lade Daten...</div>
|
|
) : balances.length === 0 ? (
|
|
<div className={styles.noData}>Keine Benutzer-Konten vorhanden</div>
|
|
) : (
|
|
<UserBalanceTable
|
|
balances={balances}
|
|
selectedMandateId={selectedMandateId}
|
|
selectedUserId={selectedUserId}
|
|
onSelectMandate={setSelectedMandateId}
|
|
onSelectUser={setSelectedUserId}
|
|
/>
|
|
)}
|
|
</section>
|
|
|
|
{/* Transactions */}
|
|
<section className={styles.section}>
|
|
<div className={styles.sectionHeader}>
|
|
<h2 className={styles.sectionTitle}>
|
|
Transaktionen
|
|
{(selectedMandateId || selectedUserId) && (
|
|
<span style={{ fontWeight: 'normal', fontSize: '14px', marginLeft: '8px' }}>
|
|
({filteredTransactionCount} gefiltert)
|
|
</span>
|
|
)}
|
|
</h2>
|
|
</div>
|
|
{loading && transactions.length === 0 ? (
|
|
<div className={styles.loadingPlaceholder}>Lade Transaktionen...</div>
|
|
) : transactions.length === 0 ? (
|
|
<div className={styles.noData}>Keine Transaktionen vorhanden</div>
|
|
) : (
|
|
<>
|
|
<UserTransactionTable
|
|
transactions={transactions}
|
|
selectedMandateId={selectedMandateId}
|
|
selectedUserId={selectedUserId}
|
|
/>
|
|
|
|
{transactions.length >= limit && (
|
|
<div style={{ textAlign: 'center', marginTop: 'var(--spacing-md)' }}>
|
|
<button
|
|
className={`${styles.button} ${styles.buttonSecondary}`}
|
|
onClick={handleLoadMore}
|
|
disabled={loading}
|
|
>
|
|
{loading ? 'Laden...' : 'Mehr laden'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BillingUserView;
|