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.
187 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|