frontend_nyla/src/pages/billing/BillingMandateView.tsx
ValueOn AG 9b99020686 feat(billing): Nutzerhinweise bei leerem Budget + Mandats-Mail (402/SSE)
Gateway
- InsufficientBalanceException: billingModel, userAction (TOP_UP_SELF /
  CONTACT_MANDATE_ADMIN), DE/EN-Texte, toClientDict(), fromBalanceCheck()
- HTTP 402 + JSON detail für globale API-Fehlerbehandlung
- AI/Chatbot: vor Raise ggf. E-Mail an BillingSettings.notifyEmails
  (PREPAY_MANDATE, Throttle 1h/Mandat) via billingExhaustedNotify
- Agent-Loop & Workspace-Route: SSE-ERROR mit strukturiertem Billing-Payload
- datamodelBilling: notifyEmails-Doku für Pool-Alerts
frontend_nyla
- useWorkspace: SSE onError für INSUFFICIENT_BALANCE mit messageDe/En
  und Hinweis auf Billing-Pfad bei TOP_UP_SELF
2026-03-21 01:34:47 +01:00

277 lines
8.9 KiB
TypeScript

/**
* Billing Mandate View Page
*
* Shows mandate-level balances and transactions for SysAdmins.
* Includes filtering by mandate.
*/
import React, { useEffect, useState, useMemo } from 'react';
import { useApiRequest } from '../../hooks/useApi';
import {
fetchMandateViewBalances,
fetchMandateViewTransactions,
type MandateBalance,
type BillingTransaction
} from '../../api/billingApi';
import { BillingNav } from './BillingNav';
import styles from './Billing.module.css';
// ============================================================================
// MANDATE BALANCE TABLE
// ============================================================================
interface MandateBalanceTableProps {
balances: MandateBalance[];
selectedMandateId: string | null;
onSelectMandate: (mandateId: string | null) => void;
}
const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
balances,
selectedMandateId,
onSelectMandate
}) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
const getBillingModelLabel = (model: string) => {
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
return 'Prepaid (Mandant)';
};
return (
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Mandant</th>
<th>Billing-Modell</th>
<th>Anzahl Benutzer</th>
<th>Standard-Guthaben</th>
<th style={{ textAlign: 'right' }}>Gesamtguthaben</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{balances.map((balance) => (
<tr
key={balance.mandateId}
className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''}
>
<td>{balance.mandateName || balance.mandateId}</td>
<td>{getBillingModelLabel(balance.billingModel)}</td>
<td>{balance.userCount}</td>
<td>{formatCurrency(balance.defaultUserCredit)}</td>
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.totalBalance)}</td>
<td>
<button
className={`${styles.button} ${styles.buttonSecondary}`}
onClick={() => onSelectMandate(
selectedMandateId === balance.mandateId ? null : balance.mandateId
)}
style={{ padding: '4px 8px', fontSize: '12px' }}
>
{selectedMandateId === balance.mandateId ? 'Alle' : 'Filter'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
// ============================================================================
// TRANSACTION TABLE
// ============================================================================
interface TransactionTableProps {
transactions: BillingTransaction[];
}
const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) => {
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 (
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Datum</th>
<th>Mandant</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>
{transactions.map((t) => (
<tr key={t.id}>
<td>{formatDate(t.createdAt)}</td>
<td>{t.mandateName || '-'}</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 BillingMandateView: React.FC = () => {
const { request, isLoading: loading } = useApiRequest();
const [balances, setBalances] = useState<MandateBalance[]>([]);
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const [limit, setLimit] = useState(100);
// Load data
useEffect(() => {
const loadData = async () => {
try {
const [balanceData, transactionData] = await Promise.all([
fetchMandateViewBalances(request),
fetchMandateViewTransactions(request, limit)
]);
setBalances(Array.isArray(balanceData) ? balanceData : []);
setTransactions(Array.isArray(transactionData) ? transactionData : []);
} catch (err) {
console.error('Error loading mandate view data:', err);
setBalances([]);
setTransactions([]);
}
};
loadData();
}, [request, limit]);
// Filter transactions by selected mandate
const filteredTransactions = useMemo(() => {
if (!selectedMandateId) return transactions;
return transactions.filter(t => t.mandateId === selectedMandateId);
}, [transactions, selectedMandateId]);
const handleLoadMore = () => {
setLimit(prev => prev + 100);
};
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Mandanten-Billing</h1>
<p className={styles.subtitle}>Guthaben und Transaktionen pro Mandant</p>
</header>
<BillingNav />
{/* Mandate Balances */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Mandanten-Guthaben</h2>
{loading && balances.length === 0 ? (
<div className={styles.loadingPlaceholder}>Lade Daten...</div>
) : balances.length === 0 ? (
<div className={styles.noData}>Keine Mandanten mit Billing-Settings vorhanden</div>
) : (
<MandateBalanceTable
balances={balances}
selectedMandateId={selectedMandateId}
onSelectMandate={setSelectedMandateId}
/>
)}
</section>
{/* Transactions */}
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>
Transaktionen
{selectedMandateId && (
<span style={{ fontWeight: 'normal', fontSize: '14px', marginLeft: '8px' }}>
(gefiltert nach {balances.find(b => b.mandateId === selectedMandateId)?.mandateName || selectedMandateId})
</span>
)}
</h2>
</div>
{loading && transactions.length === 0 ? (
<div className={styles.loadingPlaceholder}>Lade Transaktionen...</div>
) : filteredTransactions.length === 0 ? (
<div className={styles.noData}>Keine Transaktionen vorhanden</div>
) : (
<>
<TransactionTable transactions={filteredTransactions} />
{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 BillingMandateView;