All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m26s
305 lines
9.6 KiB
TypeScript
305 lines
9.6 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"
|
|
id="billing-mandate-balances"
|
|
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"
|
|
id="billing-mandate-transactions"
|
|
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;
|