feat(portal): wire Accounts/Treasury/Reporting/Compliance/Settlements/TransactionBuilder to live Chain-138 + SolaceScan

Extends the POC from #2 beyond the Dashboard so every portal page that
can benefit from on-chain signal now pulls from live backends while
preserving its existing UX. Pages without an on-chain analogue (the
IFRS/GAAP/IPSAS report rows, the dbis_core compliance alerts) stay on
sample data with an explicit 'mocked' note.

New shared primitives
---------------------
src/hooks/useLatestTransactions.ts   — polls SolaceScan /transactions every 15s
src/hooks/useAddressTransactions.ts  — per-address tx feed, 60s polling
src/components/portal/LiveTransactionsPanel.tsx  — reusable live-tx card
src/components/portal/LiveChainBanner.tsx        — slim status banner
src/components/portal/OnChainBalanceTag.tsx      — shared live/off-chain pill

Per-page wiring
---------------
AccountsPage          — on-chain pill + META balance + SolaceScan link on
                        each account row that carries a walletAddress;
                        overlay renders only on wallet rows (negative check).
SettlementsPage       — replaces the static 'Settlement Rate' tile with a
                        live Chain-138 block + tx-today tile; adds a
                        LiveTransactionsPanel above the CSD queue so the
                        page no longer renders identical output when RPC
                        is dead.
ReportingPage         — new On-Chain Reporting Snapshot row (Blockscout
                        /stats: block depth, total tx, total addrs,
                        utilisation, avg block time). Clear note that
                        the IFRS/GAAP/IPSAS rows come from dbis_core and
                        are still mocked.
TreasuryPage          — two new summary tiles: live Chain-138 gas +
                        aggregated on-chain custody (META) from sample
                        wallet addresses. Uses the same
                        useOnChainBalances hook as Accounts.
CompliancePage        — AML monitor strip with wallet selector; dedicated
                        'On-Chain Tx Feed' card shows IN/OUT per tracked
                        wallet via SolaceScan. dbis_core alerts still
                        mocked (no public deploy).
TransactionBuilder    — LiveChainBanner inserted above the composer so
                        users know chain health + gas + latency before
                        composing; transaction-builder-module made a
                        flex column so the banner doesn't cover the
                        canvas.

Assertions baked into every live widget
---------------------------------------
- RPC failure flips colour + text to 'degraded'/'—' (no silent freeze).
- Loading state is distinct from both live and degraded.
- Each overlay is only rendered where real data differs from sample data
  (walletAddress rows for balances, tracked custody for AML, etc.) so a
  page without live overlays is proof-of-scope, not proof-of-brokenness.

Verified locally
----------------
- tsc --noEmit: clean
- npm run build: clean (2066 modules, 565 ms)

Still intentionally mocked
--------------------------
- proxmox.ts — CF-Access protected; a BFF route is now open in
  orchestrator PR (see companion PR for /api/proxmox/*).
- dbisCore.ts — no public deployment exists yet.
This commit is contained in:
2026-04-19 08:31:04 +00:00
parent 007c79d7a9
commit 7253ad1974
11 changed files with 570 additions and 19 deletions

View File

@@ -8,6 +8,7 @@ import ReportingPage from './pages/ReportingPage';
import CompliancePage from './pages/CompliancePage';
import SettlementsPage from './pages/SettlementsPage';
import PortalLayout from './components/portal/PortalLayout';
import LiveChainBanner from './components/portal/LiveChainBanner';
import App from './App';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -64,8 +65,11 @@ export default function Portal() {
element={
<ProtectedRoute>
<PortalLayout>
<div className="transaction-builder-module">
<App />
<div className="transaction-builder-module" style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<LiveChainBanner />
<div style={{ flex: 1, minHeight: 0 }}>
<App />
</div>
</div>
</PortalLayout>
</ProtectedRoute>

View File

@@ -0,0 +1,50 @@
import { useLiveChain } from '../../hooks/useLiveChain';
import { endpoints } from '../../config/endpoints';
import { Zap } from 'lucide-react';
/**
* Slim one-line banner suitable for embedding above dense UIs (e.g. the
* transaction-builder canvas). Shows chain health + block + gas.
* Flips to a red "RPC degraded" state on polling failure so you don't
* accidentally compose a tx against a dead endpoint.
*/
export default function LiveChainBanner() {
const { health, error, lastUpdated } = useLiveChain();
const ok = !error && !!health;
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
padding: '6px 12px',
fontSize: 11,
background: ok ? 'rgba(34,197,94,0.06)' : error ? 'rgba(239,68,68,0.08)' : 'rgba(148,163,184,0.06)',
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}
>
<Zap size={12} style={{ color: ok ? '#22c55e' : error ? '#ef4444' : '#6b7280' }} />
<span style={{ color: '#cbd5e1' }}>
Chain {endpoints.chain138.chainId} ({endpoints.chain138.name})
</span>
<span style={{ color: ok ? '#22c55e' : error ? '#ef4444' : '#eab308' }}>
{error ? `● RPC degraded · ${error}` : ok ? '● LIVE' : '○ connecting…'}
</span>
{ok && (
<>
<span className="mono" style={{ color: '#6b7280' }}>block</span>
<span className="mono">{health.blockNumber.toLocaleString()}</span>
<span className="mono" style={{ color: '#6b7280' }}>gas</span>
<span className="mono">{health.gasPriceGwei.toFixed(4)} gwei</span>
<span className="mono" style={{ color: '#6b7280' }}>rpc</span>
<span className="mono">{health.latencyMs}ms</span>
</>
)}
<span style={{ marginLeft: 'auto', color: '#6b7280' }}>
rpc: <a href={endpoints.chain138.rpcUrl} target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>{endpoints.chain138.rpcUrl}</a>
{lastUpdated && <span style={{ marginLeft: 8 }}>· {lastUpdated.toLocaleTimeString()}</span>}
</span>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useLatestTransactions } from '../../hooks/useLatestTransactions';
import { explorerTxUrl, explorerAddressUrl, type ExplorerTx } from '../../services/explorer';
import { formatEther } from 'ethers';
import { Activity } from 'lucide-react';
const shortHash = (h: string) => `${h.slice(0, 10)}${h.slice(-6)}`;
const shortAddr = (a: string) => `${a.slice(0, 6)}${a.slice(-4)}`;
const formatMETA = (wei: string) => {
try { return `${Number(formatEther(BigInt(wei))).toFixed(4)} META`; } catch { return `${wei} wei`; }
};
const relativeTime = (iso: string) => {
const then = new Date(iso).getTime();
const dt = Date.now() - then;
if (dt < 60_000) return `${Math.max(1, Math.round(dt / 1000))}s ago`;
if (dt < 3_600_000) return `${Math.round(dt / 60_000)}m ago`;
return `${Math.round(dt / 3_600_000)}h ago`;
};
interface Props {
/** Max rows to show (default 10). */
limit?: number;
/** Custom card header label — defaults to "Live Chain-138 Transactions". */
title?: string;
}
/**
* Renders the most recent on-chain transactions from SolaceScan.
* Degraded state shows the error message; empty state shows a one-liner.
* Links every hash/address to the explorer.
*/
export default function LiveTransactionsPanel({ limit = 10, title = 'Live Chain-138 Transactions' }: Props) {
const { transactions, loading, error, lastUpdated } = useLatestTransactions(limit);
return (
<div className="dashboard-card live-transactions-card">
<div className="card-header">
<h3><Activity size={16} /> {title}</h3>
<span className="small" style={{ color: '#6b7280' }}>
{error
? <span style={{ color: '#ef4444' }}>RPC degraded · {error}</span>
: loading
? 'loading…'
: `${transactions.length} tx · ${lastUpdated ? lastUpdated.toLocaleTimeString() : '—'}`}
</span>
</div>
<div className="live-transactions-list">
{!loading && transactions.length === 0 && !error && (
<div style={{ padding: 12, color: '#6b7280', fontSize: 12 }}>
No transactions returned yet SolaceScan may be indexing.
</div>
)}
{transactions.map((tx: ExplorerTx) => (
<div
key={tx.hash}
className="live-tx-row"
style={{
display: 'grid',
gridTemplateColumns: '1.3fr 1fr 1fr 0.9fr 0.7fr 0.4fr',
gap: 8,
padding: '6px 12px',
borderBottom: '1px solid rgba(255,255,255,0.04)',
fontSize: 11,
alignItems: 'center',
}}
>
<a href={explorerTxUrl(tx.hash)} target="_blank" rel="noreferrer" className="mono" style={{ color: '#60a5fa' }}>
{shortHash(tx.hash)}
</a>
<a href={explorerAddressUrl(tx.from.hash)} target="_blank" rel="noreferrer" className="mono" style={{ color: '#cbd5e1' }}>
{shortAddr(tx.from.hash)}
</a>
<span className="mono" style={{ color: tx.to ? '#cbd5e1' : '#6b7280' }}>
{tx.to ? shortAddr(tx.to.hash) : '— contract create —'}
</span>
<span className="mono">{formatMETA(tx.value)}</span>
<span className="small" style={{ color: '#6b7280' }}>{relativeTime(tx.timestamp)}</span>
<span style={{
color: tx.status === 'error' ? '#ef4444' : tx.status === 'ok' ? '#22c55e' : '#eab308',
fontSize: 9,
}}>
{tx.status ?? 'pending'}
</span>
</div>
))}
</div>
<div style={{ padding: '6px 12px', fontSize: 10, color: '#6b7280' }}>
Source: <a href="https://explorer.d-bis.org" target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>SolaceScan Explorer</a>
{' · polls every 15s · Blockscout v2 /transactions'}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import type { OnChainBalance } from '../../services/chain138';
import { endpoints } from '../../config/endpoints';
import { explorerAddressUrl } from '../../services/explorer';
interface Props {
address: string;
balance: OnChainBalance | undefined;
loading: boolean;
compact?: boolean;
}
/**
* Renders a small pill that flips between three states:
* ● live · chain 138 — on-chain read succeeded
* ○ fetching… — initial RPC call in flight
* ○ off-chain — RPC call failed / empty state
*
* When `balance` is present, the pill becomes a link to the address page on
* SolaceScan. We never hide this tag once a walletAddress is present —
* otherwise there's no way to tell "not wired" apart from "backend is down".
*/
export default function OnChainBalanceTag({ address, balance, loading, compact }: Props) {
const color = balance ? '#22c55e' : loading ? '#eab308' : '#6b7280';
const label = balance ? `● live · chain ${endpoints.chain138.chainId}` : loading ? '○ fetching…' : '○ off-chain';
const href = explorerAddressUrl(address);
const style: React.CSSProperties = {
fontSize: compact ? 9 : 10,
color,
textDecoration: 'none',
letterSpacing: 0.2,
};
return (
<a href={href} target="_blank" rel="noreferrer" title={`View ${address} on SolaceScan`} style={style}>
{label}
{balance && (
<span className="mono" style={{ color: '#60a5fa', marginLeft: 6 }}>
{Number(balance.balanceEth).toFixed(4)} META
</span>
)}
</a>
);
}

View File

@@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getAddressTransactions, type ExplorerTx } from '../services/explorer';
export interface AddressTransactionsState {
transactions: ExplorerTx[];
loading: boolean;
error: string | null;
lastUpdated: Date | null;
refresh: () => void;
}
/**
* Fetches recent transactions for a single address from SolaceScan.
* Re-fetches on address change; also re-polls every `pollMs` (default 30s).
* Empty address short-circuits — hook returns an idle state with no error.
*/
export function useAddressTransactions(address: string | null | undefined, limit = 10, pollMs = 30_000): AddressTransactionsState {
const [transactions, setTransactions] = useState<ExplorerTx[]>([]);
const [loading, setLoading] = useState(!!address);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const mounted = useRef(true);
const tick = useCallback(async () => {
if (!address) {
setTransactions([]);
setLoading(false);
return;
}
try {
const txs = await getAddressTransactions(address, limit);
if (!mounted.current) return;
setTransactions(txs);
setError(null);
setLastUpdated(new Date());
} catch (e) {
if (!mounted.current) return;
setError(e instanceof Error ? e.message : String(e));
setTransactions([]);
} finally {
if (mounted.current) setLoading(false);
}
}, [address, limit]);
useEffect(() => {
mounted.current = true;
void tick();
if (!address) return () => { mounted.current = false; };
const id = setInterval(tick, pollMs);
return () => { mounted.current = false; clearInterval(id); };
}, [tick, address, pollMs]);
return { transactions, loading, error, lastUpdated, refresh: () => { void tick(); } };
}

View File

@@ -0,0 +1,46 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getLatestTransactions, type ExplorerTx } from '../services/explorer';
export interface LatestTransactionsState {
transactions: ExplorerTx[];
loading: boolean;
error: string | null;
lastUpdated: Date | null;
refresh: () => void;
}
/**
* Polls SolaceScan (Blockscout v2) `/transactions` every `pollMs` and
* returns the top `limit` rows. Never throws — error surfaces in state.
*/
export function useLatestTransactions(limit = 20, pollMs = 15_000): LatestTransactionsState {
const [transactions, setTransactions] = useState<ExplorerTx[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const mounted = useRef(true);
const tick = useCallback(async () => {
try {
const txs = await getLatestTransactions(limit);
if (!mounted.current) return;
setTransactions(txs);
setError(null);
setLastUpdated(new Date());
} catch (e) {
if (!mounted.current) return;
setError(e instanceof Error ? e.message : String(e));
} finally {
if (mounted.current) setLoading(false);
}
}, [limit]);
useEffect(() => {
mounted.current = true;
void tick();
const id = setInterval(tick, pollMs);
return () => { mounted.current = false; clearInterval(id); };
}, [tick, pollMs]);
return { transactions, loading, error, lastUpdated, refresh: () => { void tick(); } };
}

View File

@@ -1,10 +1,14 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import {
Building2, ChevronRight, ChevronDown, Search, Filter, Plus, Download,
ExternalLink, Copy, MoreHorizontal
} from 'lucide-react';
import { sampleAccounts } from '../data/portalData';
import type { Account, AccountType } from '../types/portal';
import { useOnChainBalances } from '../hooks/useOnChainBalances';
import type { OnChainBalance } from '../services/chain138';
import OnChainBalanceTag from '../components/portal/OnChainBalanceTag';
import { explorerAddressUrl } from '../services/explorer';
const typeColors: Record<AccountType, string> = {
operating: '#3b82f6', reserve: '#22c55e', custody: '#a855f7', escrow: '#f97316',
@@ -20,9 +24,17 @@ const formatBalance = (amount: number, currency: string) => {
return `${sym}${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
function AccountRow({ account, level = 0 }: { account: Account; level?: number }) {
interface AccountRowProps {
account: Account;
level?: number;
onChainBalances: Record<string, OnChainBalance>;
balancesLoading: boolean;
}
function AccountRow({ account, level = 0, onChainBalances, balancesLoading }: AccountRowProps) {
const [expanded, setExpanded] = useState(false);
const hasChildren = account.subaccounts && account.subaccounts.length > 0;
const onChain = account.walletAddress ? onChainBalances[account.walletAddress] : undefined;
return (
<>
@@ -39,6 +51,16 @@ function AccountRow({ account, level = 0 }: { account: Account; level?: number }
<div>
<span className="account-name-text">{account.name}</span>
<span className="account-type-label">{account.type.replace('_', ' ')}</span>
{account.walletAddress && (
<span style={{ display: 'block', marginTop: 2 }}>
<OnChainBalanceTag
address={account.walletAddress}
balance={onChain}
loading={balancesLoading}
compact
/>
</span>
)}
</div>
</div>
<div className="account-table-cell currency">{account.currency}</div>
@@ -49,7 +71,18 @@ function AccountRow({ account, level = 0 }: { account: Account; level?: number }
</div>
<div className="account-table-cell identifier">
{account.iban && <span className="mono small">{account.iban}</span>}
{account.walletAddress && <span className="mono small">{account.walletAddress.slice(0, 10)}...</span>}
{account.walletAddress && (
<a
href={explorerAddressUrl(account.walletAddress)}
target="_blank"
rel="noreferrer"
className="mono small"
title="View on SolaceScan"
style={{ color: 'inherit', textDecoration: 'underline dotted' }}
>
{account.walletAddress.slice(0, 10)}
</a>
)}
{account.swift && <span className="swift-badge">{account.swift}</span>}
</div>
<div className="account-table-cell">
@@ -62,7 +95,7 @@ function AccountRow({ account, level = 0 }: { account: Account; level?: number }
</div>
</div>
{expanded && hasChildren && account.subaccounts!.map(sub => (
<AccountRow key={sub.id} account={sub} level={level + 1} />
<AccountRow key={sub.id} account={sub} level={level + 1} onChainBalances={onChainBalances} balancesLoading={balancesLoading} />
))}
</>
);
@@ -73,6 +106,15 @@ export default function AccountsPage() {
const [typeFilter, setTypeFilter] = useState<string>('all');
const [view, setView] = useState<'tree' | 'flat'>('tree');
const onChainAddresses = useMemo(
() => sampleAccounts
.flatMap(a => [a, ...(a.subaccounts || [])])
.filter(a => !!a.walletAddress)
.map(a => a.walletAddress as string),
[],
);
const { balances: onChainBalances, loading: balancesLoading } = useOnChainBalances(onChainAddresses);
const allAccounts = view === 'flat'
? sampleAccounts.flatMap(a => [a, ...(a.subaccounts || [])])
: sampleAccounts;
@@ -175,7 +217,12 @@ export default function AccountsPage() {
</div>
<div className="account-table-body">
{filtered.map(acc => (
<AccountRow key={acc.id} account={acc} />
<AccountRow
key={acc.id}
account={acc}
onChainBalances={onChainBalances}
balancesLoading={balancesLoading}
/>
))}
</div>
</div>

View File

@@ -1,6 +1,10 @@
import { useState } from 'react';
import { Shield, AlertTriangle, CheckCircle2, Clock, Filter, Download, Eye, UserCheck } from 'lucide-react';
import { complianceAlerts } from '../data/portalData';
import { useMemo, useState } from 'react';
import { Shield, AlertTriangle, CheckCircle2, Clock, Filter, Download, Eye, UserCheck, Activity } from 'lucide-react';
import { complianceAlerts, sampleAccounts } from '../data/portalData';
import { useLiveChain } from '../hooks/useLiveChain';
import { useAddressTransactions } from '../hooks/useAddressTransactions';
import { explorerAddressUrl } from '../services/explorer';
import { endpoints } from '../config/endpoints';
const severityColors: Record<string, string> = {
critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#3b82f6',
@@ -29,6 +33,21 @@ const regulatoryFrameworks = [
export default function CompliancePage() {
const [severityFilter, setSeverityFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const { health, error: liveErr } = useLiveChain();
const tracked = useMemo(
() => sampleAccounts
.flatMap(a => [a, ...(a.subaccounts || [])])
.filter(a => !!a.walletAddress)
.map(a => ({ name: a.name, address: a.walletAddress as string, type: a.type })),
[],
);
const [selectedWallet, setSelectedWallet] = useState(tracked[0]?.address ?? '');
const {
transactions: walletTxs,
loading: walletLoading,
error: walletErr,
} = useAddressTransactions(selectedWallet, 10, 60_000);
const filtered = complianceAlerts.filter(a => {
const matchSev = severityFilter === 'all' || a.severity === severityFilter;
@@ -39,6 +58,8 @@ export default function CompliancePage() {
const openCount = complianceAlerts.filter(a => a.status === 'open').length;
const criticalCount = complianceAlerts.filter(a => a.severity === 'critical' && a.status !== 'resolved').length;
const selectedWalletName = tracked.find(t => t.address === selectedWallet)?.name ?? '';
return (
<div className="compliance-page">
<div className="page-header">
@@ -52,6 +73,44 @@ export default function CompliancePage() {
</div>
</div>
{/* On-Chain AML Monitoring strip */}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 12,
alignItems: 'center',
marginBottom: 12,
padding: '10px 14px',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 8,
background: 'rgba(255,255,255,0.02)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<Activity size={14} />
<span style={{ fontSize: 12, color: '#cbd5e1' }}>
On-chain AML monitor Chain {endpoints.chain138.chainId}
</span>
<span style={{ fontSize: 11, color: liveErr ? '#ef4444' : '#22c55e' }}>
{liveErr ? `● degraded · ${liveErr}` : health ? `● live · block ${health.blockNumber.toLocaleString()}` : '○ polling…'}
</span>
<span style={{ fontSize: 11, color: '#6b7280' }}>
Tracked custody wallets: {tracked.length}
</span>
</div>
<select
value={selectedWallet}
onChange={e => setSelectedWallet(e.target.value)}
style={{ fontSize: 11, background: 'transparent', color: '#cbd5e1', padding: '4px 6px' }}
>
{tracked.length === 0 && <option value="">No tracked wallets</option>}
{tracked.map(t => (
<option key={t.address} value={t.address}>{t.name} · {t.address.slice(0, 8)}</option>
))}
</select>
</div>
{/* Compliance Metrics */}
<div className="compliance-metrics">
{complianceMetrics.map(m => (
@@ -116,6 +175,60 @@ export default function CompliancePage() {
</div>
</div>
{/* On-chain transactions for the selected tracked wallet */}
<div className="dashboard-card">
<div className="card-header">
<h3><Activity size={16} /> On-Chain Tx Feed</h3>
<span className="small" style={{ color: '#6b7280' }}>
{selectedWalletName ? selectedWalletName : 'select a wallet above'}
{walletLoading ? ' · loading…' : walletErr ? ` · ${walletErr}` : ''}
</span>
</div>
<div style={{ maxHeight: 220, overflowY: 'auto' }}>
{!walletLoading && walletTxs.length === 0 && !walletErr && (
<div style={{ padding: 12, fontSize: 11, color: '#6b7280' }}>
No on-chain activity for this wallet yet.
{selectedWallet && (
<> View on <a
href={explorerAddressUrl(selectedWallet)}
target="_blank"
rel="noreferrer"
style={{ color: '#60a5fa' }}
>SolaceScan</a>.</>
)}
</div>
)}
{walletTxs.map(tx => (
<div
key={tx.hash}
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 0.8fr 0.5fr',
gap: 6,
padding: '6px 12px',
fontSize: 11,
borderBottom: '1px solid rgba(255,255,255,0.04)',
}}
>
<span className="mono" style={{ color: '#60a5fa' }}>
<a href={`${endpoints.explorer.baseUrl}/tx/${tx.hash}`} target="_blank" rel="noreferrer" style={{ color: 'inherit' }}>
{tx.hash.slice(0, 14)}
</a>
</span>
<span className="mono">
{tx.from.hash.toLowerCase() === selectedWallet.toLowerCase() ? 'OUT →' : 'IN ←'}
{' '}
{(tx.from.hash.toLowerCase() === selectedWallet.toLowerCase() ? tx.to?.hash : tx.from.hash)?.slice(0, 10) ?? '—'}
</span>
<span className="small mono">{new Date(tx.timestamp).toLocaleTimeString()}</span>
<span style={{ color: tx.status === 'error' ? '#ef4444' : '#22c55e', fontSize: 9 }}>
{tx.status ?? 'pending'}
</span>
</div>
))}
</div>
</div>
{/* Regulatory Frameworks */}
<div className="dashboard-card regulatory-card">
<div className="card-header">

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { FileText, Download, Filter, Plus, Eye, Clock, CheckCircle2, AlertTriangle, Send } from 'lucide-react';
import { FileText, Download, Filter, Plus, Eye, Clock, CheckCircle2, AlertTriangle, Send, Database } from 'lucide-react';
import { reportConfigs } from '../data/portalData';
import type { ReportingStandard } from '../types/portal';
import { useLiveChain } from '../hooks/useLiveChain';
import { endpoints } from '../config/endpoints';
const standardColors: Record<ReportingStandard, string> = {
IPSAS: '#a855f7',
@@ -27,6 +29,7 @@ export default function ReportingPage() {
const [standardFilter, setStandardFilter] = useState<string>('all');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [activeStandard, setActiveStandard] = useState<ReportingStandard>('IFRS');
const { health, stats, error: liveErr, lastUpdated: liveUpdatedAt } = useLiveChain();
const filtered = reportConfigs.filter(r => {
const matchStandard = standardFilter === 'all' || r.standard === standardFilter;
@@ -70,6 +73,63 @@ export default function ReportingPage() {
</div>
</div>
{/* On-Chain Reporting Snapshot — live data from Chain-138 + SolaceScan */}
<div
className="onchain-report-snapshot"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: 12,
marginBottom: 16,
padding: 12,
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 8,
background: 'rgba(255,255,255,0.02)',
}}
>
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#cbd5e1' }}>
<Database size={14} /> On-Chain Reporting Snapshot Chain {endpoints.chain138.chainId}
</span>
<span className="small" style={{ color: liveErr ? '#ef4444' : '#6b7280' }}>
{liveErr ? `RPC degraded · ${liveErr}` : liveUpdatedAt ? `updated ${liveUpdatedAt.toLocaleTimeString()}` : 'polling…'}
</span>
</div>
<div>
<span className="summary-label" style={{ fontSize: 10 }}>Latest Block</span>
<div className="mono" style={{ fontSize: 18 }}>{liveErr ? '—' : (health?.blockNumber?.toLocaleString() ?? '…')}</div>
</div>
<div>
<span className="summary-label" style={{ fontSize: 10 }}>Total Blocks (ledger depth)</span>
<div className="mono" style={{ fontSize: 18 }}>{liveErr ? '—' : (stats?.total_blocks?.toLocaleString() ?? '…')}</div>
</div>
<div>
<span className="summary-label" style={{ fontSize: 10 }}>Total Transactions</span>
<div className="mono" style={{ fontSize: 18 }}>{liveErr ? '—' : (stats?.total_transactions?.toLocaleString() ?? '…')}</div>
</div>
<div>
<span className="summary-label" style={{ fontSize: 10 }}>Total Addresses</span>
<div className="mono" style={{ fontSize: 18 }}>{liveErr ? '—' : (stats?.total_addresses?.toLocaleString() ?? '…')}</div>
</div>
<div>
<span className="summary-label" style={{ fontSize: 10 }}>Network Utilisation</span>
<div className="mono" style={{ fontSize: 18 }}>
{liveErr ? '—' : (stats ? `${stats.network_utilization_percentage.toFixed(1)}%` : '…')}
</div>
</div>
<div>
<span className="summary-label" style={{ fontSize: 10 }}>Avg Block Time</span>
<div className="mono" style={{ fontSize: 18 }}>
{liveErr ? '—' : (stats ? `${stats.average_block_time.toFixed(1)}s` : '…')}
</div>
</div>
<div style={{ gridColumn: '1 / -1', fontSize: 10, color: '#6b7280' }}>
Sources: <a href={endpoints.chain138.rpcUrl} target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>{endpoints.chain138.rpcUrl}</a>
{' · '}<a href={`${endpoints.explorer.apiBaseUrl}/api/v2/stats`} target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>{endpoints.explorer.apiBaseUrl}/api/v2/stats</a>
{' · the IFRS / US GAAP / IPSAS reports below are generated by dbis_core (currently mocked — no public deployment).'}
</div>
</div>
{/* Standards Overview */}
<div className="standards-tabs">
{(['IPSAS', 'US_GAAP', 'IFRS'] as ReportingStandard[]).map(std => (

View File

@@ -1,6 +1,8 @@
import { useState } from 'react';
import { CheckSquare, Filter, Download, Clock, CheckCircle2, XCircle, ArrowUpDown } from 'lucide-react';
import { settlementRecords } from '../data/portalData';
import LiveTransactionsPanel from '../components/portal/LiveTransactionsPanel';
import { useLiveChain } from '../hooks/useLiveChain';
const statusColors: Record<string, string> = {
pending: '#eab308', matched: '#3b82f6', affirmed: '#a855f7',
@@ -26,6 +28,7 @@ export default function SettlementsPage() {
const [statusFilter, setStatusFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const [sortBy, setSortBy] = useState<'date' | 'amount'>('date');
const { health, stats, error: liveErr } = useLiveChain();
const filtered = settlementRecords
.filter(s => (statusFilter === 'all' || s.status === statusFilter) && (typeFilter === 'all' || s.type === typeFilter))
@@ -52,24 +55,31 @@ export default function SettlementsPage() {
<div className="settlements-summary">
<div className="summary-card">
<span className="summary-label">Pending</span>
<span className="summary-label">Pending (CSD)</span>
<span className="summary-value orange">{pendingCount}</span>
<span className="summary-sub">{formatCurrency(totalPending, 'USD')} total</span>
</div>
<div className="summary-card">
<span className="summary-label">Settled</span>
<span className="summary-label">Settled (CSD)</span>
<span className="summary-value green">{settledCount}</span>
</div>
<div className="summary-card">
<span className="summary-label">Failed</span>
<span className="summary-label">Failed (CSD)</span>
<span className="summary-value red">{failedCount}</span>
</div>
<div className="summary-card">
<span className="summary-label">Settlement Rate</span>
<span className="summary-value">{settledCount > 0 ? ((settledCount / (settledCount + failedCount)) * 100).toFixed(0) : 0}%</span>
<span className="summary-label">Chain-138 Block</span>
<span className="summary-value" style={{ color: liveErr ? '#ef4444' : '#22c55e' }}>
{liveErr ? '—' : health?.blockNumber?.toLocaleString() ?? '…'}
</span>
<span className="summary-sub">
{liveErr ? 'RPC degraded' : stats ? `${stats.transactions_today.toLocaleString()} tx today` : 'polling…'}
</span>
</div>
</div>
<LiveTransactionsPanel limit={12} />
<div className="dashboard-card">
<div className="card-header">
<h3>Settlement Queue</h3>

View File

@@ -1,6 +1,9 @@
import { useState } from 'react';
import { Landmark, TrendingUp, TrendingDown, Download, Filter, ArrowUpDown, RefreshCw } from 'lucide-react';
import { treasuryPositions, cashForecasts } from '../data/portalData';
import { useMemo, useState } from 'react';
import { Landmark, TrendingUp, TrendingDown, Download, Filter, ArrowUpDown, RefreshCw, Zap } from 'lucide-react';
import { treasuryPositions, cashForecasts, sampleAccounts } from '../data/portalData';
import { useLiveChain } from '../hooks/useLiveChain';
import { useOnChainBalances } from '../hooks/useOnChainBalances';
import { endpoints } from '../config/endpoints';
const formatCurrency = (amount: number) => {
if (Math.abs(amount) >= 1_000_000) return `$${(amount / 1_000_000).toFixed(2)}M`;
@@ -11,6 +14,18 @@ const formatCurrency = (amount: number) => {
export default function TreasuryPage() {
const [assetFilter, setAssetFilter] = useState('all');
const [sortBy, setSortBy] = useState<'value' | 'pnl' | 'name'>('value');
const { health, error: liveErr } = useLiveChain();
const custodyAddresses = useMemo(
() => sampleAccounts
.flatMap(a => [a, ...(a.subaccounts || [])])
.filter(a => !!a.walletAddress)
.map(a => a.walletAddress as string),
[],
);
const { balances: onChainBalances } = useOnChainBalances(custodyAddresses);
const totalOnChainMETA = Object.values(onChainBalances)
.reduce((sum, b) => sum + Number(b.balanceEth || 0), 0);
const assetClasses = [...new Set(treasuryPositions.map(p => p.assetClass))];
@@ -63,6 +78,24 @@ export default function TreasuryPage() {
{totalPnL >= 0 ? '+' : ''}{((totalPnL / totalCostBasis) * 100).toFixed(2)}%
</span>
</div>
<div className="summary-card">
<span className="summary-label"><Zap size={11} /> Chain-138 Gas</span>
<span className="summary-value" style={{ color: liveErr ? '#ef4444' : '#60a5fa' }}>
{liveErr ? '—' : health ? `${health.gasPriceGwei.toFixed(3)} gwei` : '…'}
</span>
<span className="summary-sub">
{liveErr ? 'RPC degraded' : health ? `block ${health.blockNumber.toLocaleString()}` : 'polling…'}
</span>
</div>
<div className="summary-card">
<span className="summary-label">On-Chain Custody (META)</span>
<span className="summary-value">
{totalOnChainMETA.toFixed(4)}
</span>
<span className="summary-sub">
{custodyAddresses.length} custody wallet{custodyAddresses.length === 1 ? '' : 's'} · chain {endpoints.chain138.chainId}
</span>
</div>
</div>
<div className="treasury-grid">