feat: clarify Central Bank and Office 24 dashboard layout
Some checks failed
CI/CD Pipeline / Solidity Contracts (push) Failing after 1m21s
CI/CD Pipeline / Security Scanning (push) Successful in 2m42s
CI/CD Pipeline / Lint and Format (push) Failing after 52s
CI/CD Pipeline / Terraform Validation (push) Failing after 28s
CI/CD Pipeline / Kubernetes Validation (push) Successful in 44s
Validation / validate-genesis (push) Successful in 31s
Validation / validate-terraform (push) Failing after 28s
Validation / validate-kubernetes (push) Failing after 12s
Validation / validate-smart-contracts (push) Failing after 12s
Validation / validate-security (push) Failing after 1m21s
Validation / validate-documentation (push) Failing after 20s
Verify Deployment / Verify Deployment (push) Failing after 1m20s

Add status banners, grouped money-supply sections, public portal links, and shared dashboard components for clearer operator views.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-28 16:19:56 -07:00
parent b72b986a12
commit 4e047b9429
9 changed files with 610 additions and 228 deletions

View File

@@ -7,6 +7,7 @@ const PRODUCTS = [
{ path: '/central-bank', label: 'Central Bank', short: 'CB' },
{ path: '/office-24', label: 'Office 24', short: 'O24' },
{ path: '/trade', label: 'DBIS Trade', short: 'Trade' },
{ path: '/swap', label: 'Swap', short: 'Swap' },
] as const;
export default function OmnlProductLayout() {

View File

@@ -1,30 +1,12 @@
import { Link } from 'react-router-dom';
import { useCentralBank } from './useCentralBank';
import ChainMatrixPanel from './ChainMatrixPanel';
import { SETTLEMENT_MIDDLEWARE_URL, DBIS_EXCHANGE_URL } from '../../config/dex';
import FineractLedgerBanner from '../omnl-dashboard/FineractLedgerBanner';
import DashboardStatCard from '../omnl-dashboard/DashboardStatCard';
import DashboardSection from '../omnl-dashboard/DashboardSection';
import QuickPortalLinks from '../omnl-dashboard/QuickPortalLinks';
import { formatUsd } from '../../utils/formatMoney';
function StatCard({
label,
value,
sub,
accent,
}: {
label: string;
value: string;
sub?: string;
accent?: 'gold' | 'green' | 'blue';
}) {
const border =
accent === 'gold' ? 'border-l-[#f0b90b]' : accent === 'green' ? 'border-l-[#0ecb81]' : 'border-l-[#3861fb]';
return (
<div className={`cb-card border-l-4 ${border}`}>
<p className="text-xs text-[#848e9c] uppercase tracking-wide mb-1">{label}</p>
<p className="text-2xl font-semibold text-[#eaecef]">{value}</p>
{sub && <p className="text-xs text-[#848e9c] mt-1">{sub}</p>}
</div>
);
}
export default function CentralBankDashboard() {
const cb = useCentralBank();
const office = cb.office as {
@@ -35,157 +17,172 @@ export default function CentralBankDashboard() {
} | null;
return (
<div className="cb-page max-w-[1400px] mx-auto px-4 py-6">
<header className="mb-8">
<div className="flex items-center gap-3 mb-2">
<span className="px-2 py-0.5 rounded text-xs font-medium bg-[#3861fb]/20 text-[#3861fb]">
OMNL Central Bank
</span>
<span className="px-2 py-0.5 rounded text-xs font-medium bg-[#0ecb81]/20 text-[#0ecb81]">
Production · 128 chains
</span>
<span className="text-xs text-[#848e9c]">Meta fiat · M0 / M1 / M2</span>
<div className="dash-page cb-page max-w-[1400px] mx-auto px-4 py-6">
<header className="mb-6">
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="dash-badge dash-badge--blue">OMNL Central Bank</span>
<span className="dash-badge dash-badge--green">Production</span>
<span className="dash-badge dash-badge--gold">128 chains</span>
</div>
<h1 className="text-3xl font-bold text-[#eaecef]">Central Bank Operations</h1>
<p className="text-[#848e9c] mt-1">
Settlement orchestration · SWIFT · HYBX · Chain 138 token loading
<h1 className="dash-page-title">Central Bank Operations</h1>
<p className="dash-page-subtitle">
Live money supply for {office?.name ?? 'HOSPITALLERS Ali Iraq-Iran'} (Office{' '}
{office?.officeId ?? 24}) · settlement · SWIFT · HYBX · Chain 138
</p>
</header>
<QuickPortalLinks />
{cb.error && (
<div className="mb-4 p-3 rounded bg-[#f6465d]/10 text-[#f6465d] text-sm">{cb.error}</div>
<div className="dash-banner dash-banner--error mb-6">
<p className="dash-banner__title">Failed to load dashboard</p>
<p className="dash-banner__detail">{cb.error}</p>
</div>
)}
<FineractLedgerBanner
moneySupply={cb.moneySupply}
loading={cb.loading}
officeName={office?.name}
/>
<h2 className="dash-section-title mb-1">Money supply</h2>
<p className="dash-section-subtitle mb-4">
Meta-fiat layers from Fineract general ledger · USD
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard
label="M0 Reserve (GL 1050)"
<DashboardStatCard
label="M0 reserve"
value={formatUsd(cb.moneySupply?.M0?.gl1050)}
sub={`Backing ${cb.moneySupply?.M0?.backingRatio ?? 1.2}×`}
hint={`GL 1050 · ${cb.moneySupply?.M0?.backingRatio ?? 1.2}× backing policy`}
accent="blue"
/>
<StatCard
label="M1 Circulating (GL 2100)"
<DashboardStatCard
label="M1 circulating"
value={formatUsd(cb.moneySupply?.M1?.circulating)}
sub="Narrow money · eMoney"
hint="GL 2100 · narrow money / eMoney"
accent="green"
/>
<StatCard
label="M2 Broad (GL 2200)"
<DashboardStatCard
label="M2 broad money"
value={formatUsd(cb.moneySupply?.M2?.broadMoney)}
sub="Token load source"
hint="GL 2200 · token-load source"
accent="gold"
/>
<StatCard
label="M2 tokens registered"
value={String(cb.tokenCount)}
sub="Swappable · convertible · transferable"
accent="gold"
<DashboardStatCard
label="Due from head office"
value={formatUsd(cb.moneySupply?.interoffice?.dueFromHeadOffice)}
hint="GL 1410 · inter-office receivable"
accent="blue"
/>
</div>
{cb.moneySupply?.fineractConnected && !cb.moneySupply?.fineractLive && (
<p className="text-xs text-[#f0b90b] mb-6">
Fineract connected for Office 24 no opening journal yet. Post Dr 1410 / Cr 2100 via{' '}
<code className="text-[#f0b90b]">seed-office-24-opening-journal.mjs</code>.
</p>
)}
{cb.moneySupply?.fineractConnected === false && (
<p className="text-xs text-[#f6465d] mb-6">
Fineract unreachable check OMNL_FINERACT_* on settlement middleware.
</p>
)}
{cb.moneySupply?.asOf && (
<p className="text-xs text-[#848e9c] mb-6">
Live Fineract · Office {cb.moneySupply.officeId ?? 24} · as of{' '}
{cb.moneySupply.asOf.slice(0, 19).replace('T', ' ')} UTC
{cb.moneySupply.interoffice?.dueFromHeadOffice
? ` · Due from HO (GL 1410): ${formatUsd(cb.moneySupply.interoffice.dueFromHeadOffice)}`
: ''}
</p>
)}
{!cb.moneySupply && (
<p className="text-xs text-[#848e9c] mb-6">
Money supply loads from{' '}
<code className="text-[#f0b90b]">/money-supply/public</code> when online bank mode is enabled.
</p>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section className="cb-card">
<h2 className="text-lg font-semibold text-[#eaecef] mb-4">Settlement service</h2>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-[#848e9c]">Status</dt>
<dd className="text-[#0ecb81] font-medium">{(cb.health as { status?: string })?.status ?? '—'}</dd>
<DashboardSection
title="Settlement service"
subtitle="Middleware health and refresh"
action={
<button
type="button"
className="dash-btn !w-auto px-4"
onClick={() => cb.refresh()}
disabled={cb.loading}
>
{cb.loading ? 'Refreshing…' : 'Refresh'}
</button>
}
>
<dl>
<div className="dash-kv">
<dt>Status</dt>
<dd className="dash-kv-value--ok">{(cb.health as { status?: string })?.status ?? '—'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-[#848e9c]">Role</dt>
<dd className="text-[#eaecef] text-right max-w-[60%]">
{(cb.health as { role?: string })?.role ?? '—'}
<div className="dash-kv">
<dt>Office</dt>
<dd>
{office?.officeId ?? 24} · {office?.externalId ?? 'HOSPITALLERS-ALI-IRAQ-IRAN'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-[#848e9c]">API</dt>
<dd className="text-[#f0b90b] text-xs truncate max-w-[60%]">{SETTLEMENT_MIDDLEWARE_URL}</dd>
<div className="dash-kv">
<dt>M2 tokens registered</dt>
<dd>{cb.tokenCount}</dd>
</div>
<div className="dash-kv">
<dt>Active chains</dt>
<dd>
{(cb.health as { chainSupport?: { active?: number; total?: number } })?.chainSupport
?.active ?? '—'}
{' / '}
{(cb.health as { chainSupport?: { active?: number; total?: number } })?.chainSupport
?.total ?? 128}
</dd>
</div>
</dl>
<button
type="button"
className="mt-4 w-full py-2 rounded bg-[#2b3139] text-[#eaecef] text-sm hover:bg-[#474d57]"
onClick={() => cb.refresh()}
>
{cb.loading ? 'Refreshing…' : 'Refresh'}
</button>
</section>
</DashboardSection>
<section className="cb-card">
<h2 className="text-lg font-semibold text-[#eaecef] mb-4">Money layers (Office {office?.officeId ?? 24})</h2>
<DashboardSection
title="General ledger map"
subtitle={`Money layers · Office ${office?.officeId ?? 24}`}
>
<div className="flex flex-wrap gap-2 mb-4">
{(office?.settlement?.moneyLayers ?? ['M0', 'M1', 'M2']).map((layer) => (
<span key={layer} className="px-3 py-1 rounded-full bg-[#2b3139] text-[#f0b90b] text-xs font-medium">
<span
key={layer}
className="px-3 py-1 rounded-full bg-[#2b3139] text-[#f0b90b] text-xs font-semibold"
>
{layer}
</span>
))}
</div>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-[#848e9c]">GL M0</dt>
<dd>{office?.settlement?.glM0 ?? '1050'}</dd>
<dl>
<div className="dash-kv">
<dt>M0 reserve</dt>
<dd>GL {office?.settlement?.glM0 ?? '1050'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-[#848e9c]">GL M1</dt>
<dd>{office?.settlement?.glM1 ?? '2100'}</dd>
<div className="dash-kv">
<dt>M1 liability</dt>
<dd>GL {office?.settlement?.glM1 ?? '2100'}</dd>
</div>
<div className="flex justify-between">
<dt className="text-[#848e9c]">GL M2</dt>
<dd>{office?.settlement?.glM2 ?? '2200'}</dd>
<div className="dash-kv">
<dt>M2 broad</dt>
<dd>GL {office?.settlement?.glM2 ?? '2200'}</dd>
</div>
<div className="dash-kv">
<dt>Inter-office (HO)</dt>
<dd>GL 1410</dd>
</div>
</dl>
</section>
<p className="dash-section-subtitle mt-4">
<Link to="/office-24" className="text-[#f0b90b] hover:underline">
Open Office 24 dashboard
</Link>
{' · '}
<Link to="/trade" className="text-[#f0b90b] hover:underline">
DBIS Trade
</Link>
</p>
</DashboardSection>
<section className="cb-card lg:col-span-2">
<h2 className="text-lg font-semibold text-[#eaecef] mb-4">Recent exchange activity</h2>
<ul className="space-y-2 text-sm">
<DashboardSection title="Recent exchange activity" subtitle="Last 8 swaps on DBIS Exchange" className="lg:col-span-2">
<ul className="space-y-0 text-sm">
{((cb.monitor as { swaps?: { status: string; tokenIn: string; createdAt: string }[] })?.swaps ?? [])
.slice(0, 8)
.map((s, i) => (
<li key={i} className="flex justify-between py-2 border-b border-[#2b3139] last:border-0">
<span className="text-[#848e9c]">{s.status}</span>
<span className="text-[#eaecef] font-mono text-xs truncate max-w-[50%]">{s.tokenIn?.slice(0, 10)}</span>
<span className="text-[#848e9c] text-xs">{s.createdAt?.slice(0, 16)}</span>
<li
key={i}
className="flex justify-between gap-3 py-2.5 border-b border-[#2b3139] last:border-0 text-sm"
>
<span className="text-[#848e9c] shrink-0">{s.status}</span>
<span className="text-[#eaecef] font-mono text-xs truncate">{s.tokenIn?.slice(0, 12)}</span>
<span className="text-[#848e9c] text-xs shrink-0">{s.createdAt?.slice(0, 16)}</span>
</li>
))}
{!((cb.monitor as { swaps?: unknown[] })?.swaps?.length) && (
<li className="text-[#848e9c]">No swaps recorded yet</li>
<li className="text-[#848e9c] py-3">No swaps recorded yet</li>
)}
</ul>
<p className="text-xs text-[#848e9c] mt-4">
DBIS Exchange: {DBIS_EXCHANGE_URL} · Token-load &amp; transfers via settlement middleware
</p>
</section>
</DashboardSection>
<ChainMatrixPanel />
</div>

View File

@@ -24,7 +24,7 @@ export default function ChainMatrixPanel() {
});
return (
<section className="cb-card lg:col-span-2">
<section className="dash-card cb-card lg:col-span-2">
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h2 className="text-lg font-semibold text-[#eaecef]">128-chain network matrix</h2>

View File

@@ -1,5 +1,9 @@
import { Link } from 'react-router-dom';
import { useOffice24 } from './useOffice24';
import FineractLedgerBanner from '../omnl-dashboard/FineractLedgerBanner';
import DashboardStatCard from '../omnl-dashboard/DashboardStatCard';
import DashboardSection from '../omnl-dashboard/DashboardSection';
import QuickPortalLinks from '../omnl-dashboard/QuickPortalLinks';
import { formatUsd } from '../../utils/formatMoney';
export default function Office24Dashboard() {
@@ -15,123 +19,147 @@ export default function Office24Dashboard() {
} | null;
return (
<div className="o24-page max-w-[1400px] mx-auto px-4 py-6">
<header className="mb-8 flex flex-wrap items-start justify-between gap-4">
<div className="dash-page o24-page max-w-[1400px] mx-auto px-4 py-6">
<header className="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-2">
<span className="px-2 py-0.5 rounded text-xs font-medium bg-[#0ecb81]/20 text-[#0ecb81]">
Office 24 · Locked
</span>
{profile?.locked && <span className="text-xs text-[#848e9c]">Single-office enforcement</span>}
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="dash-badge dash-badge--green">Office 24</span>
{profile?.locked && <span className="dash-badge dash-badge--gold">Single office · locked</span>}
</div>
<h1 className="text-3xl font-bold text-[#eaecef]">{profile?.name ?? 'HOSPITALLERS Ali Iraq-Iran'}</h1>
<p className="text-[#848e9c] mt-1 font-mono text-sm">{profile?.externalId ?? 'HOSPITALLERS-ALI-IRAQ-IRAN'}</p>
<h1 className="dash-page-title">{profile?.name ?? 'HOSPITALLERS Ali Iraq-Iran'}</h1>
<p className="dash-page-subtitle font-mono text-sm">{profile?.externalId ?? 'HOSPITALLERS-ALI-IRAQ-IRAN'}</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
to="/central-bank"
className="px-4 py-2.5 rounded-md border border-[#474d57] text-[#eaecef] text-sm font-medium hover:border-[#f0b90b] hover:text-[#f0b90b]"
>
Central Bank
</Link>
<Link
to="/trade"
className="px-4 py-2.5 rounded-md bg-[#f0b90b] text-[#181a20] font-semibold text-sm hover:bg-[#fcd535]"
>
DBIS Trade
</Link>
</div>
<Link
to="/trade"
className="px-5 py-2.5 rounded bg-[#f0b90b] text-[#181a20] font-semibold text-sm hover:bg-[#fcd535]"
>
Open DBIS Trade
</Link>
</header>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="o24-card">
<p className="text-xs text-[#848e9c] uppercase">Office ID</p>
<p className="text-2xl font-bold text-[#0ecb81]">{profile?.officeId ?? 24}</p>
</div>
<div className="o24-card">
<p className="text-xs text-[#848e9c] uppercase">M1 circulating (GL 2100)</p>
<p className="text-2xl font-bold text-[#eaecef]">
{formatUsd(moneySupply?.M1?.circulating)}
</p>
</div>
<div className="o24-card">
<p className="text-xs text-[#848e9c] uppercase">Due from HO (GL 1410)</p>
<p className="text-2xl font-bold text-[#3861fb]">
{formatUsd(moneySupply?.interoffice?.dueFromHeadOffice)}
</p>
</div>
<div className="o24-card">
<p className="text-xs text-[#848e9c] uppercase">M2 tradable tokens</p>
<p className="text-2xl font-bold text-[#f0b90b]">{tokens.length}</p>
</div>
<QuickPortalLinks />
<FineractLedgerBanner moneySupply={moneySupply} loading={loading} officeName={profile?.name} />
<h2 className="dash-section-title mb-1">Office balances</h2>
<p className="dash-section-subtitle mb-4">
Live Fineract · {profile?.settlement?.defaultCurrency ?? 'USD'}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<DashboardStatCard
label="Office ID"
value={String(profile?.officeId ?? 24)}
hint="Fineract office · HOSPITALLERS Ali"
accent="green"
/>
<DashboardStatCard
label="M1 circulating"
value={formatUsd(moneySupply?.M1?.circulating)}
hint="GL 2100 · spendable M1 liability"
accent="green"
/>
<DashboardStatCard
label="Due from head office"
value={formatUsd(moneySupply?.interoffice?.dueFromHeadOffice)}
hint="GL 1410 · HO allocation receivable"
accent="blue"
/>
<DashboardStatCard
label="M2 tokens"
value={String(tokens.length)}
hint="Registered · swap · convert · transfer"
accent="gold"
/>
</div>
{moneySupply?.fineractConnected && !moneySupply?.fineractLive && (
<p className="text-sm text-[#f0b90b] mb-6">
Fineract connected post opening M1: Dr 1410 / Cr 2100 via{' '}
<code className="text-[#f0b90b]">scripts/deployment/seed-office-24-opening-journal.mjs</code>
</p>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<section className="o24-card lg:col-span-1">
<h2 className="font-semibold text-[#eaecef] mb-4">Settlement rails</h2>
<ul className="space-y-3 text-sm">
<li className="flex justify-between">
<span className="text-[#848e9c]">SWIFT</span>
<span className={profile?.rails?.swift?.enabled ? 'text-[#0ecb81]' : 'text-[#848e9c]'}>
{profile?.rails?.swift?.enabled ? 'Enabled' : 'Off'}
</span>
</li>
<li className="flex justify-between">
<span className="text-[#848e9c]">HYBX</span>
<span className={profile?.rails?.hybx?.enabled ? 'text-[#0ecb81]' : 'text-[#848e9c]'}>
{profile?.rails?.hybx?.enabled ? 'Enabled' : 'Off'}
</span>
</li>
<li className="flex justify-between">
<span className="text-[#848e9c]">Chain 138</span>
<span className={profile?.rails?.chain138?.enabled ? 'text-[#0ecb81]' : 'text-[#848e9c]'}>
{profile?.rails?.chain138?.enabled ? 'Enabled' : 'Off'}
</span>
</li>
<li className="flex justify-between">
<span className="text-[#848e9c]">Exchange</span>
<span className="text-[#0ecb81]">{(health as { status?: string })?.status ?? 'ok'}</span>
</li>
</ul>
<p className="text-xs text-[#848e9c] mt-4">Tenant: {profile?.tenantUser ?? 'ali_hospitallers_tenant'}</p>
</section>
<section className="o24-card lg:col-span-2">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-[#eaecef]">M2 token registry swap · convert · transfer</h2>
<button
type="button"
onClick={() => refresh()}
className="text-xs text-[#f0b90b] hover:underline"
>
{loading ? '…' : 'Refresh'}
<DashboardSection
title="Settlement rails"
subtitle="Enabled payment and chain paths"
action={
<button type="button" onClick={() => refresh()} className="text-xs text-[#f0b90b] hover:underline">
{loading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
<div className="overflow-x-auto max-h-[420px] overflow-y-auto">
}
>
<dl>
<div className="dash-kv">
<dt>SWIFT</dt>
<dd className={profile?.rails?.swift?.enabled ? 'dash-kv-value--ok' : undefined}>
{profile?.rails?.swift?.enabled ? 'Enabled' : 'Off'}
</dd>
</div>
<div className="dash-kv">
<dt>HYBX</dt>
<dd className={profile?.rails?.hybx?.enabled ? 'dash-kv-value--ok' : undefined}>
{profile?.rails?.hybx?.enabled ? 'Enabled' : 'Off'}
</dd>
</div>
<div className="dash-kv">
<dt>Chain 138</dt>
<dd className={profile?.rails?.chain138?.enabled ? 'dash-kv-value--ok' : undefined}>
{profile?.rails?.chain138?.enabled ? 'Enabled' : 'Off'}
</dd>
</div>
<div className="dash-kv">
<dt>DBIS Exchange</dt>
<dd className="dash-kv-value--ok">{(health as { status?: string })?.status ?? 'ok'}</dd>
</div>
<div className="dash-kv">
<dt>Fineract tenant</dt>
<dd className="text-xs">{profile?.tenantUser ?? 'ali_hospitallers_tenant'}</dd>
</div>
</dl>
</DashboardSection>
<DashboardSection
title="M2 token registry"
subtitle="Tradable lines on Chain 138"
className="lg:col-span-2"
>
<div className="overflow-x-auto max-h-[420px] overflow-y-auto rounded-md border border-[#2b3139]">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[#1e2329]">
<thead className="sticky top-0 bg-[#181a20]">
<tr className="text-[#848e9c] text-left text-xs uppercase">
<th className="py-2 pr-4">Symbol</th>
<th className="py-2 pr-4">Line</th>
<th className="py-2 pr-4">Swap</th>
<th className="py-2 pr-4">Convert</th>
<th className="py-2">Xfer</th>
<th className="py-2.5 px-3">Symbol</th>
<th className="py-2.5 px-3">Line</th>
<th className="py-2.5 px-3">Swap</th>
<th className="py-2.5 px-3">Convert</th>
<th className="py-2.5 px-3">Transfer</th>
</tr>
</thead>
<tbody>
{tokens.map((t) => (
<tr key={t.address} className="border-t border-[#2b3139] hover:bg-[#2b3139]/50">
<td className="py-2 pr-4 font-medium text-[#eaecef]">{t.symbol}</td>
<td className="py-2 pr-4 text-[#848e9c] text-xs">{t.omnlLine ?? 'M2'}</td>
<td className="py-2 pr-4">{t.swappable !== false ? '' : '—'}</td>
<td className="py-2 pr-4">{t.convertible !== false ? '' : '—'}</td>
<td className="py-2 text-[#0ecb81]">
{t.transferableInternal && t.transferableExternal ? 'Int+Ext' : ''}
<tr key={t.address} className="border-t border-[#2b3139] hover:bg-[#2b3139]/40">
<td className="py-2.5 px-3 font-medium text-[#eaecef]">{t.symbol}</td>
<td className="py-2.5 px-3 text-[#848e9c] text-xs">{t.omnlLine ?? 'M2'}</td>
<td className="py-2.5 px-3 text-[#0ecb81]">{t.swappable !== false ? 'Yes' : '—'}</td>
<td className="py-2.5 px-3 text-[#0ecb81]">{t.convertible !== false ? 'Yes' : '—'}</td>
<td className="py-2.5 px-3 text-[#0ecb81]">
{t.transferableInternal && t.transferableExternal ? 'Internal + external' : 'Yes'}
</td>
</tr>
))}
{!tokens.length && (
<tr>
<td colSpan={5} className="py-6 px-3 text-center text-[#848e9c]">
No M2 tokens loaded
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</DashboardSection>
</div>
</div>
);

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
export default function DashboardSection({
title,
subtitle,
action,
children,
className = '',
}: {
title: string;
subtitle?: string;
action?: React.ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={`dash-card ${className}`}>
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
<div>
<h2 className="dash-section-title">{title}</h2>
{subtitle && <p className="dash-section-subtitle">{subtitle}</p>}
</div>
{action}
</div>
{children}
</section>
);
}

View File

@@ -0,0 +1,28 @@
type Accent = 'gold' | 'green' | 'blue' | 'neutral';
const ACCENT: Record<Accent, string> = {
blue: 'border-l-[#3861fb]',
green: 'border-l-[#0ecb81]',
gold: 'border-l-[#f0b90b]',
neutral: 'border-l-[#848e9c]',
};
export default function DashboardStatCard({
label,
value,
hint,
accent = 'neutral',
}: {
label: string;
value: string;
hint?: string;
accent?: Accent;
}) {
return (
<div className={`dash-card border-l-4 ${ACCENT[accent]}`}>
<p className="dash-stat-label">{label}</p>
<p className="dash-stat-value">{value}</p>
{hint && <p className="dash-stat-hint">{hint}</p>}
</div>
);
}

View File

@@ -0,0 +1,72 @@
import type { MoneySupply } from '../central-bank/useCentralBank';
type Props = {
moneySupply: MoneySupply | null;
loading?: boolean;
officeName?: string;
};
function formatAsOf(asOf?: string) {
if (!asOf) return null;
return asOf.slice(0, 19).replace('T', ' ') + ' UTC';
}
export default function FineractLedgerBanner({ moneySupply, loading, officeName }: Props) {
if (loading && !moneySupply) {
return (
<div className="dash-banner dash-banner--loading mb-6">
<p className="dash-banner__title">Loading live ledger balances</p>
<p className="dash-banner__detail">Fetching Fineract data for Office 24</p>
</div>
);
}
if (!moneySupply) {
return (
<div className="dash-banner dash-banner--warn mb-6">
<p className="dash-banner__title">Ledger data not available</p>
<p className="dash-banner__detail">
The money-supply API did not respond. Check settlement middleware on port 3011.
</p>
</div>
);
}
const asOf = formatAsOf(moneySupply.asOf);
const officeLabel = officeName ?? `Office ${moneySupply.officeId ?? 24}`;
if (moneySupply.fineractConnected === false) {
return (
<div className="dash-banner dash-banner--error mb-6">
<p className="dash-banner__title">Fineract offline</p>
<p className="dash-banner__detail">
Cannot reach Fineract for {officeLabel}. Verify OMNL_FINERACT_* credentials on the
settlement service.
</p>
</div>
);
}
if (moneySupply.fineractConnected && !moneySupply.fineractLive) {
return (
<div className="dash-banner dash-banner--warn mb-6">
<p className="dash-banner__title">Fineract connected opening journal pending</p>
<p className="dash-banner__detail">
{officeLabel} has no M1 allocation yet. Post the opening entry: Debit GL 1410 (Due from
Head Office) · Credit GL 2100 (M1 liabilities).
{asOf ? ` · Checked ${asOf}` : ''}
</p>
</div>
);
}
return (
<div className="dash-banner dash-banner--ok mb-6">
<p className="dash-banner__title">Live Fineract ledger · {officeLabel}</p>
<p className="dash-banner__detail">
M0 / M1 / M2 balances below are read from Fineract in real time.
{asOf ? ` Last updated ${asOf}.` : ''}
</p>
</div>
);
}

View File

@@ -0,0 +1,28 @@
const LINKS = [
{ label: 'Central Bank', href: 'https://secure.omdnl.org/central-bank' },
{ label: 'Online banking', href: 'https://online.omdnl.org/central-bank' },
{ label: 'Office 24', href: 'https://office24.omdnl.org/office-24' },
{ label: 'DBIS Trade', href: 'https://exchange.omdnl.org/trade' },
{ label: 'Digital swap', href: 'https://digital.omdnl.org/swap' },
] as const;
export default function QuickPortalLinks() {
return (
<div className="dash-quick-links mb-8">
<p className="dash-quick-links__label">Public portals</p>
<div className="flex flex-wrap gap-2">
{LINKS.map(({ label, href }) => (
<a
key={href}
href={href}
target="_blank"
rel="noopener noreferrer"
className="dash-quick-links__chip"
>
{label}
</a>
))}
</div>
</div>
);
}

View File

@@ -1,18 +1,218 @@
/* OMNL product UIs — Central Bank, Office 24, Binance-style trade */
.cb-page,
.o24-page {
.o24-page,
.dash-page {
min-height: calc(100vh - 56px);
}
.cb-card,
.o24-card {
.o24-card,
.dash-card {
background: #1e2329;
border: 1px solid #2b3139;
border-radius: 4px;
border-radius: 8px;
padding: 1.25rem;
}
.dash-page-title {
font-size: 1.875rem;
font-weight: 700;
color: #eaecef;
line-height: 1.2;
}
.dash-page-subtitle {
margin-top: 0.35rem;
color: #848e9c;
font-size: 0.95rem;
max-width: 42rem;
}
.dash-badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.dash-badge--blue {
background: rgba(56, 97, 251, 0.18);
color: #3861fb;
}
.dash-badge--green {
background: rgba(14, 203, 129, 0.18);
color: #0ecb81;
}
.dash-badge--gold {
background: rgba(240, 185, 11, 0.18);
color: #f0b90b;
}
.dash-banner {
border-radius: 8px;
padding: 1rem 1.15rem;
border: 1px solid #2b3139;
}
.dash-banner__title {
font-size: 0.95rem;
font-weight: 600;
color: #eaecef;
margin-bottom: 0.25rem;
}
.dash-banner__detail {
font-size: 0.85rem;
color: #848e9c;
line-height: 1.45;
}
.dash-banner--ok {
background: rgba(14, 203, 129, 0.08);
border-color: rgba(14, 203, 129, 0.35);
}
.dash-banner--ok .dash-banner__title {
color: #0ecb81;
}
.dash-banner--warn {
background: rgba(240, 185, 11, 0.08);
border-color: rgba(240, 185, 11, 0.35);
}
.dash-banner--warn .dash-banner__title {
color: #f0b90b;
}
.dash-banner--error {
background: rgba(246, 70, 93, 0.08);
border-color: rgba(246, 70, 93, 0.35);
}
.dash-banner--error .dash-banner__title {
color: #f6465d;
}
.dash-banner--loading {
background: #1e2329;
}
.dash-stat-label {
font-size: 0.7rem;
color: #848e9c;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.35rem;
}
.dash-stat-value {
font-size: 1.65rem;
font-weight: 700;
color: #eaecef;
font-variant-numeric: tabular-nums;
}
.dash-stat-hint {
margin-top: 0.35rem;
font-size: 0.75rem;
color: #848e9c;
}
.dash-section-title {
font-size: 1.05rem;
font-weight: 600;
color: #eaecef;
}
.dash-section-subtitle {
margin-top: 0.2rem;
font-size: 0.8rem;
color: #848e9c;
}
.dash-kv {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.45rem 0;
font-size: 0.875rem;
border-bottom: 1px solid #2b3139;
}
.dash-kv:last-child {
border-bottom: none;
}
.dash-kv dt {
color: #848e9c;
flex-shrink: 0;
}
.dash-kv dd {
color: #eaecef;
text-align: right;
word-break: break-word;
}
.dash-kv .dash-kv-value--ok {
color: #0ecb81;
font-weight: 500;
}
.dash-quick-links__label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #848e9c;
margin-bottom: 0.5rem;
}
.dash-quick-links__chip {
display: inline-block;
padding: 0.35rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
color: #eaecef;
background: #2b3139;
border: 1px solid #474d57;
transition: background 0.15s, border-color 0.15s;
}
.dash-quick-links__chip:hover {
background: #474d57;
border-color: #f0b90b;
color: #f0b90b;
}
.dash-btn {
width: 100%;
padding: 0.55rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
background: #2b3139;
color: #eaecef;
transition: background 0.15s;
}
.dash-btn:hover {
background: #474d57;
}
.dash-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Binance-style trade terminal */
.trade-layout {
display: grid;