Files
CurrenciCombo/src/pages/TreasuryPage.tsx
Nakamoto, S 7253ad1974 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.
2026-04-19 08:31:04 +00:00

187 lines
8.2 KiB
TypeScript

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`;
if (Math.abs(amount) >= 1_000) return `$${(amount / 1_000).toFixed(1)}K`;
return `$${amount.toFixed(2)}`;
};
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))];
const filtered = treasuryPositions
.filter(p => assetFilter === 'all' || p.assetClass === assetFilter)
.sort((a, b) => {
if (sortBy === 'value') return b.marketValue - a.marketValue;
if (sortBy === 'pnl') return b.unrealizedPnL - a.unrealizedPnL;
return a.instrument.localeCompare(b.instrument);
});
const totalMarketValue = treasuryPositions.reduce((s, p) => s + p.marketValue, 0);
const totalCostBasis = treasuryPositions.reduce((s, p) => s + p.costBasis, 0);
const totalPnL = treasuryPositions.reduce((s, p) => s + p.unrealizedPnL, 0);
const forecastData = cashForecasts.slice(0, 14);
return (
<div className="treasury-page">
<div className="page-header">
<div>
<h1><Landmark size={24} /> Treasury Management</h1>
<p className="page-subtitle">Position monitoring, cash management, and portfolio analytics</p>
</div>
<div className="page-header-actions">
<button className="btn-secondary"><RefreshCw size={14} /> Refresh Prices</button>
<button className="btn-secondary"><Download size={14} /> Export Positions</button>
</div>
</div>
{/* Portfolio Summary */}
<div className="treasury-summary">
<div className="summary-card">
<span className="summary-label">Total Market Value</span>
<span className="summary-value">{formatCurrency(totalMarketValue)}</span>
</div>
<div className="summary-card">
<span className="summary-label">Total Cost Basis</span>
<span className="summary-value">{formatCurrency(totalCostBasis)}</span>
</div>
<div className="summary-card">
<span className="summary-label">Unrealized P&L</span>
<span className={`summary-value ${totalPnL >= 0 ? 'green' : 'red'}`}>
{totalPnL >= 0 ? '+' : ''}{formatCurrency(totalPnL)}
</span>
</div>
<div className="summary-card">
<span className="summary-label">Return</span>
<span className={`summary-value ${totalPnL >= 0 ? 'green' : 'red'}`}>
{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">
{/* Positions Table */}
<div className="dashboard-card positions-table-card">
<div className="card-header">
<h3><TrendingUp size={16} /> Positions</h3>
<div className="card-header-actions">
<div className="filter-group">
<Filter size={12} />
<select value={assetFilter} onChange={e => setAssetFilter(e.target.value)}>
<option value="all">All Asset Classes</option>
{assetClasses.map(ac => (
<option key={ac} value={ac}>{ac}</option>
))}
</select>
</div>
<div className="filter-group">
<ArrowUpDown size={12} />
<select value={sortBy} onChange={e => setSortBy(e.target.value as 'value' | 'pnl' | 'name')}>
<option value="value">Sort by Value</option>
<option value="pnl">Sort by P&L</option>
<option value="name">Sort by Name</option>
</select>
</div>
</div>
</div>
<div className="treasury-table">
<div className="treasury-table-header">
<span>Instrument</span>
<span>Asset Class</span>
<span>Quantity</span>
<span>Market Value</span>
<span>Cost Basis</span>
<span>Unrealized P&L</span>
<span>Custodian</span>
</div>
{filtered.map(pos => (
<div key={pos.id} className="treasury-table-row">
<span className="instrument-name">{pos.instrument}</span>
<span><span className="asset-class-badge">{pos.assetClass}</span></span>
<span className="mono">{pos.quantity.toLocaleString()}</span>
<span className="mono">{formatCurrency(pos.marketValue)}</span>
<span className="mono">{formatCurrency(pos.costBasis)}</span>
<span className={`mono ${pos.unrealizedPnL >= 0 ? 'positive' : 'negative'}`}>
{pos.unrealizedPnL >= 0 ? <TrendingUp size={10} /> : <TrendingDown size={10} />}
{' '}{pos.unrealizedPnL >= 0 ? '+' : ''}{formatCurrency(pos.unrealizedPnL)}
</span>
<span className="custodian-name">{pos.custodian}</span>
</div>
))}
</div>
</div>
{/* Cash Forecast */}
<div className="dashboard-card forecast-card">
<div className="card-header">
<h3>📈 14-Day Cash Forecast</h3>
</div>
<div className="forecast-chart">
{forecastData.map((f, i) => {
const maxVal = Math.max(...forecastData.map(x => x.projected));
const minVal = Math.min(...forecastData.map(x => x.projected));
const range = maxVal - minVal || 1;
const height = ((f.projected - minVal) / range) * 80 + 20;
return (
<div key={i} className="forecast-bar-wrapper">
<div
className={`forecast-bar ${f.actual ? 'actual' : ''}`}
style={{ height: `${height}%` }}
title={`${f.date.toLocaleDateString()}: $${(f.projected / 1_000_000).toFixed(1)}M`}
/>
<span className="forecast-label">
{f.date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
</div>
);
})}
</div>
<div className="forecast-legend">
<span><span className="legend-dot actual" /> Actual</span>
<span><span className="legend-dot projected" /> Projected</span>
</div>
</div>
</div>
</div>
);
}