ui-nyla/src/pages/billing/BillingMandateView.tsx
ValueOn AG d579df1c92
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s
panel ui
2026-06-11 16:43:53 +02:00

303 lines
9.5 KiB
TypeScript

// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* 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 { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
import styles from './Billing.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// ============================================================================
// MANDATE BALANCE TABLE
// ============================================================================
interface MandateBalanceTableProps {
balances: MandateBalance[];
selectedMandateId: string | null;
onSelectMandate: (mandateId: string | null) => void;
}
const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({ balances,
selectedMandateId,
onSelectMandate
}) => {
const { t } = useLanguage();
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF'
}).format(amount);
};
return (
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>{t('Mandant')}</th>
<th>{t('Anzahl Benutzer')}</th>
<th>{t('Warnschwelle')}</th>
<th style={{ textAlign: 'right' }}>{t('Gesamtguthaben')}</th>
<th>{t('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>{balance.userCount}</td>
<td>{balance.warningThresholdPercent}%</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 ? t('Alle') : t('Filter')}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
// ============================================================================
// TRANSACTION TABLE
// ============================================================================
interface TransactionTableProps {
transactions: BillingTransaction[];
}
const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) => {
const { t } = useLanguage();
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 t('Gutschrift');
case 'DEBIT': return t('Belastung');
case 'ADJUSTMENT': return t('Korrektur');
default: return type;
}
};
return (
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>{t('Datum')}</th>
<th>{t('Mandant')}</th>
<th>{t('Typ')}</th>
<th>{t('Beschreibung')}</th>
<th>{t('Anbieter')}</th>
<th>{t('Modell')}</th>
<th>{t('Feature')}</th>
<th style={{ textAlign: 'right' }}>{t('Betrag')}</th>
</tr>
</thead>
<tbody>
{transactions.map((txn) => (
<tr key={txn.id}>
<td>{formatDate(txn.sysCreatedAt)}</td>
<td>{txn.mandateName || '-'}</td>
<td>
<span className={`${styles.transactionType} ${getTypeClass(txn.transactionType)}`}>
{getTypeLabel(txn.transactionType)}
</span>
</td>
<td>{txn.description}</td>
<td>{txn.aicoreProvider || '-'}</td>
<td>{txn.aicoreModel || '-'}</td>
<td>{txn.featureCode || '-'}</td>
<td style={{ textAlign: 'right' }}>
{txn.transactionType === 'DEBIT' ? '-' : '+'}{formatCurrency(txn.amount)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
interface BillingMandateViewProps {
embedded?: boolean;
}
export const BillingMandateView: React.FC<BillingMandateViewProps> = ({ embedded = false }) => {
const { t } = useLanguage();
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(tx => tx.mandateId === selectedMandateId);
}, [transactions, selectedMandateId]);
const handleLoadMore = () => {
setLimit(prev => prev + 100);
};
const _filteredMandateName = selectedMandateId
? balances.find(b => b.mandateId === selectedMandateId)?.mandateName || selectedMandateId
: null;
const _transactionsSubtitle = selectedMandateId
? t('(gefiltert nach {name})', { name: _filteredMandateName ?? selectedMandateId })
: undefined;
const _body = (
<>
<Panel
variant="card"
collapsible
defaultCollapsed={false}
collapseKey="billing-mandate-balances"
title={t('Mandanten-Guthaben')}
>
{loading && balances.length === 0 ? (
<div className={styles.loadingPlaceholder}>{t('Daten laden')}</div>
) : balances.length === 0 ? (
<div className={styles.noData}>{t('Keine Mandanten mit Billing-Einstellungen vorhanden')}</div>
) : (
<MandateBalanceTable
balances={balances}
selectedMandateId={selectedMandateId}
onSelectMandate={setSelectedMandateId}
/>
)}
</Panel>
<Panel
variant="card"
collapsible
defaultCollapsed={false}
collapseKey="billing-mandate-transactions"
title={t('Transaktionen')}
subtitle={_transactionsSubtitle}
>
{loading && transactions.length === 0 ? (
<div className={styles.loadingPlaceholder}>{t('Transaktionen laden')}</div>
) : filteredTransactions.length === 0 ? (
<div className={styles.noData}>{t('Keine Transaktionen vorhanden')}</div>
) : (
<>
<TransactionTable transactions={filteredTransactions} />
{transactions.length >= limit && (
<div className={styles.loadMoreRow}>
<button
type="button"
className={`${styles.button} ${styles.buttonSecondary}`}
onClick={handleLoadMore}
disabled={loading}
>
{loading ? t('Laden') : t('Mehr laden')}
</button>
</div>
)}
</>
)}
</Panel>
</>
);
if (embedded) {
return <div>{_body}</div>;
}
return (
<StackLayout variant="scroll">
<StackLayout.Header>
<div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, margin: 0 }}>{t('Mandanten-Billing')}</h1>
<p className={styles.subtitle}>{t('Guthaben und Transaktionen pro Mandant')}</p>
</div>
</StackLayout.Header>
<StackLayout.Body>
<BillingNav />
{_body}
</StackLayout.Body>
</StackLayout>
);
};
export default BillingMandateView;