PR G: portal /transactions page + 12-state machine view #11

Merged
nsatoshi merged 1 commits from devin/1776876388-portal-transactions into main 2026-04-22 17:18:53 +00:00
7 changed files with 714 additions and 2 deletions

View File

@@ -7,6 +7,7 @@ import TreasuryPage from './pages/TreasuryPage';
import ReportingPage from './pages/ReportingPage';
import CompliancePage from './pages/CompliancePage';
import SettlementsPage from './pages/SettlementsPage';
import TransactionsPage from './pages/TransactionsPage';
import PortalLayout from './components/portal/PortalLayout';
import LiveChainBanner from './components/portal/LiveChainBanner';
import App from './App';
@@ -131,6 +132,28 @@ export default function Portal() {
}
/>
<Route
path="/transactions"
element={
<ProtectedRoute>
<PortalLayout>
<TransactionsPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/transactions/:planId"
element={
<ProtectedRoute>
<PortalLayout>
<TransactionsPage />
</PortalLayout>
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={

View File

@@ -4,12 +4,13 @@ import { useAuth } from '../../contexts/AuthContext';
import {
LayoutDashboard, Zap, Building2, Landmark, FileText, Shield, CheckSquare,
Settings, LogOut, ChevronLeft, ChevronRight, Bell, User, Copy,
ExternalLink, ChevronDown
ExternalLink, ChevronDown, GitBranch
} from 'lucide-react';
const navItems = [
{ id: 'dashboard', label: 'Overview', icon: LayoutDashboard, path: '/dashboard' },
{ id: 'transaction-builder', label: 'Transaction Builder', icon: Zap, path: '/transaction-builder' },
{ id: 'transactions', label: 'Transactions', icon: GitBranch, path: '/transactions' },
{ id: 'accounts', label: 'Accounts', icon: Building2, path: '/accounts' },
{ id: 'treasury', label: 'Treasury', icon: Landmark, path: '/treasury' },
{ id: 'reporting', label: 'Reporting', icon: FileText, path: '/reporting' },

View File

@@ -0,0 +1,51 @@
import { TRANSACTION_STATES, type StateTransition, type TransactionState } from '../../services/orchestrator';
interface StateMachineViewProps {
current: TransactionState;
transitions: StateTransition[];
}
/**
* Renders the 12-state transaction machine from the architecture note
* §8. Visited states are highlighted in the order they were entered;
* the current state is emphasised. Intended as an audit-friendly view
* for the /transactions page, NOT a full graph editor.
*/
export default function StateMachineView({ current, transitions }: StateMachineViewProps) {
const visited = new Set<string>(transitions.map((t) => t.to_state));
if (transitions.length > 0 && transitions[0].from_state === null) {
visited.add(transitions[0].to_state);
}
return (
<div className="state-machine-view">
<div className="state-machine-grid">
{TRANSACTION_STATES.map((state) => {
const isCurrent = state === current;
const isVisited = visited.has(state);
const isTerminal = state === 'COMMITTED' || state === 'ABORTED' || state === 'CLOSED';
const classes = [
'state-pill',
isCurrent ? 'state-pill--current' : '',
!isCurrent && isVisited ? 'state-pill--visited' : '',
!isVisited ? 'state-pill--pending' : '',
isTerminal ? 'state-pill--terminal' : '',
]
.filter(Boolean)
.join(' ');
return (
<div key={state} className={classes} data-testid={`state-${state}`}>
<span className="state-pill-dot" aria-hidden="true" />
<span className="state-pill-label">{state.replace(/_/g, ' ')}</span>
</div>
);
})}
</div>
<div className="state-machine-legend">
<span className="legend-item"><span className="dot dot--current" />current</span>
<span className="legend-item"><span className="dot dot--visited" />visited</span>
<span className="legend-item"><span className="dot dot--pending" />not yet reached</span>
</div>
</div>
);
}

View File

@@ -40,6 +40,13 @@ export interface EndpointConfig {
* banking API is stood up. */
mocked: true;
};
orchestrator: {
/** CurrenciCombo/orchestrator base URL (plan-state + event stream
* for /transactions page). Empty string means "not deployed —
* fall back to mock demo data". */
baseUrl: string;
deployed: boolean;
};
}
const env = (import.meta as unknown as { env?: Record<string, string> }).env ?? {};
@@ -66,12 +73,16 @@ export const endpoints: EndpointConfig = {
apiBaseUrl: env.VITE_DBIS_CORE_API_BASE_URL || 'https://api.dbis-core.d-bis.org',
mocked: true,
},
orchestrator: {
baseUrl: env.VITE_ORCHESTRATOR_URL || '',
deployed: Boolean(env.VITE_ORCHESTRATOR_URL),
},
};
export type BackendStatus = 'live' | 'bff-required' | 'mocked' | 'degraded';
export interface BackendDescriptor {
id: 'chain138' | 'explorer' | 'proxmox' | 'dbisCore';
id: 'chain138' | 'explorer' | 'proxmox' | 'dbisCore' | 'orchestrator';
name: string;
status: BackendStatus;
url: string;
@@ -107,4 +118,13 @@ export const backendCatalog: BackendDescriptor[] = [
url: endpoints.dbisCore.apiBaseUrl,
note: 'No public deployment yet. UI falls back to sample portal data.',
},
{
id: 'orchestrator',
name: 'Transaction Orchestrator',
status: endpoints.orchestrator.deployed ? 'live' : 'mocked',
url: endpoints.orchestrator.baseUrl || '(not deployed)',
note: endpoints.orchestrator.deployed
? 'CurrenciCombo orchestrator — plan state + event stream.'
: 'Orchestrator not yet deployed. /transactions page renders demo plans.',
},
];

View File

@@ -3851,3 +3851,96 @@ html, body, #root {
border-radius: 4px;
color: var(--accent);
}
/* ================================================================= */
/* /transactions page (PR G — arch step 8) */
/* ================================================================= */
.transactions-page { padding: 24px; display: flex; flex-direction: column; gap: 20px; }
.transactions-page .back-button {
background: none; border: none; color: var(--accent);
cursor: pointer; font-size: 13px; padding: 0; margin-bottom: 8px;
}
.transactions-page .back-button:hover { text-decoration: underline; }
.source-badge {
font-size: 10px; letter-spacing: 0.08em; padding: 2px 8px;
border-radius: 10px; font-weight: 600; text-transform: uppercase;
}
.source-badge--live { background: rgba(34,197,94,0.15); color: #22c55e; }
.source-badge--degraded { background: rgba(239,68,68,0.15); color: #ef4444; }
.source-badge--mocked { background: rgba(148,163,184,0.20); color: #94a3b8; }
.portal-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.portal-table th, .portal-table td { padding: 10px 12px; text-align: left; border-bottom: 1px solid rgba(148,163,184,0.12); }
.portal-table th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #94a3b8; font-weight: 600; }
.portal-table tbody tr { transition: background 0.12s ease; }
.portal-table .portal-table-row { cursor: pointer; }
.portal-table .portal-table-row:hover { background: rgba(99,102,241,0.06); }
.portal-table .mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; }
.portal-table .truncate { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.portal-table .row-chevron { color: #64748b; }
.state-chip, .role-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px; border-radius: 999px; font-size: 11px;
font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
background: rgba(99,102,241,0.14); color: #a5b4fc;
}
.state-chip--committed { background: rgba(34,197,94,0.15); color: #22c55e; }
.state-chip--aborted { background: rgba(239,68,68,0.15); color: #ef4444; }
.state-chip--validating,
.state-chip--executing,
.state-chip--partially_executed { background: rgba(245,158,11,0.15); color: #f59e0b; }
.state-chip--draft { background: rgba(148,163,184,0.18); color: #cbd5e1; }
.state-chip--closed { background: rgba(148,163,184,0.25); color: #e2e8f0; }
.role-chip--submitter { background: rgba(99,102,241,0.14); color: #a5b4fc; }
.role-chip--approver { background: rgba(245,158,11,0.14); color: #f59e0b; }
.role-chip--releaser { background: rgba(14,165,233,0.14); color: #38bdf8; }
.role-chip--validator { background: rgba(168,85,247,0.14); color: #c084fc; }
.role-chip--coordinator{ background: rgba(148,163,184,0.18); color: #cbd5e1; }
.state-machine-view { padding: 12px 8px 4px; }
.state-machine-grid {
display: grid; gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.state-pill {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; border-radius: 10px;
border: 1px solid rgba(148,163,184,0.18);
background: rgba(15,23,42,0.35); color: #e2e8f0;
font-size: 12px; font-weight: 500; letter-spacing: 0.03em;
}
.state-pill-dot {
width: 10px; height: 10px; border-radius: 50%;
background: rgba(148,163,184,0.45);
}
.state-pill--visited { border-color: rgba(99,102,241,0.35); }
.state-pill--visited .state-pill-dot { background: #818cf8; }
.state-pill--current {
border-color: #22c55e;
box-shadow: 0 0 0 2px rgba(34,197,94,0.18);
background: rgba(34,197,94,0.08);
}
.state-pill--current .state-pill-dot { background: #22c55e; }
.state-pill--pending { opacity: 0.55; }
.state-pill--terminal.state-pill--visited { border-color: #f59e0b; }
.state-machine-legend {
display: flex; gap: 16px; padding: 12px 4px 0;
font-size: 11px; color: #94a3b8;
}
.legend-item { display: inline-flex; align-items: center; gap: 6px; }
.legend-item .dot { width: 8px; height: 8px; border-radius: 50%; }
.legend-item .dot--current { background: #22c55e; }
.legend-item .dot--visited { background: #818cf8; }
.legend-item .dot--pending { background: rgba(148,163,184,0.45); }
.loading-row, .empty-row { padding: 20px; color: #94a3b8; text-align: center; font-size: 13px; }
.error-banner {
padding: 10px 14px; border-radius: 8px; font-size: 12px;
background: rgba(239,68,68,0.10); color: #fca5a5;
border: 1px solid rgba(239,68,68,0.25); margin: 8px 0;
}
.muted { color: #94a3b8; }

View File

@@ -0,0 +1,243 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { listPlans, getPlanState, getPlanEvents, type PlanSummary, type PlanStateDetail, type PlanEvent } from '../services/orchestrator';
import StateMachineView from '../components/portal/StateMachineView';
import { endpoints } from '../config/endpoints';
type Source = 'live' | 'mocked' | 'degraded';
function SourceBadge({ source }: { source: Source }) {
const label = source === 'live' ? 'LIVE' : source === 'degraded' ? 'DEGRADED' : 'DEMO';
return <span className={`source-badge source-badge--${source}`}>{label}</span>;
}
export default function TransactionsPage() {
const { planId } = useParams<{ planId?: string }>();
return planId ? <TransactionDetail planId={planId} /> : <TransactionsList />;
}
function TransactionsList() {
const navigate = useNavigate();
const [plans, setPlans] = useState<PlanSummary[] | null>(null);
const [source, setSource] = useState<Source>('mocked');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setPlans(null);
setError(null);
listPlans()
.then((res) => {
if (cancelled) return;
setPlans(res.plans);
setSource(res.source);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, []);
return (
<div className="transactions-page">
<div className="page-header">
<h1>Transactions</h1>
<p className="page-subtitle">
Multi-layer atomic settlement plans. State machine per architecture note §8.
{!endpoints.orchestrator.deployed && (
<span className="muted">
{' '}Orchestrator not deployed showing demo plans.
</span>
)}
</p>
</div>
<div className="dashboard-card">
<div className="card-header">
<h3>Recent plans</h3>
<SourceBadge source={source} />
</div>
{error && <div className="error-banner">{error}</div>}
{plans === null ? (
<div className="loading-row">Loading</div>
) : plans.length === 0 ? (
<div className="empty-row">No plans yet.</div>
) : (
<table className="portal-table" data-testid="transactions-table">
<thead>
<tr>
<th>Plan ID</th>
<th>State</th>
<th>Instrument</th>
<th>Owner</th>
<th>Updated</th>
<th aria-label="open" />
</tr>
</thead>
<tbody>
{plans.map((p) => (
<tr
key={p.plan_id}
className="portal-table-row"
onClick={() => navigate(`/transactions/${encodeURIComponent(p.plan_id)}`)}
data-testid={`plan-row-${p.plan_id}`}
>
<td className="mono">{p.plan_id}</td>
<td>
<span className={`state-chip state-chip--${p.status.toLowerCase()}`}>
{p.status.replace(/_/g, ' ')}
</span>
</td>
<td>{p.instrument_hint ?? '—'}</td>
<td>{p.actor_id ?? '—'}</td>
<td>{new Date(p.updated_at).toLocaleString()}</td>
<td className="row-chevron"></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
function TransactionDetail({ planId }: { planId: string }) {
const navigate = useNavigate();
const [detail, setDetail] = useState<PlanStateDetail | null>(null);
const [events, setEvents] = useState<PlanEvent[] | null>(null);
const [source, setSource] = useState<Source>('mocked');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setDetail(null);
setEvents(null);
setError(null);
Promise.all([getPlanState(planId), getPlanEvents(planId)])
.then(([s, e]) => {
if (cancelled) return;
setDetail(s.detail);
setEvents(e.events);
setSource(s.source === 'live' && e.source === 'live' ? 'live' : s.source);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, [planId]);
return (
<div className="transactions-page">
<div className="page-header">
<button className="back-button" onClick={() => navigate('/transactions')}>
All transactions
</button>
<h1>
Plan <span className="mono">{planId}</span>
</h1>
<p className="page-subtitle">
{detail ? (
<>Current state: <strong>{detail.current_state.replace(/_/g, ' ')}</strong></>
) : (
'Loading plan state…'
)}
</p>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="dashboard-card">
<div className="card-header">
<h3>12-state machine</h3>
<SourceBadge source={source} />
</div>
{detail ? (
<StateMachineView current={detail.current_state} transitions={detail.transitions} />
) : (
<div className="loading-row">Loading</div>
)}
</div>
<div className="dashboard-card">
<div className="card-header">
<h3>Audit trail</h3>
</div>
{detail === null ? (
<div className="loading-row">Loading</div>
) : detail.transitions.length === 0 ? (
<div className="empty-row">No transitions recorded.</div>
) : (
<table className="portal-table" data-testid="audit-trail">
<thead>
<tr>
<th>#</th>
<th>From To</th>
<th>Actor</th>
<th>Role</th>
<th>Reason</th>
<th>At</th>
</tr>
</thead>
<tbody>
{detail.transitions.map((t, i) => (
<tr key={i}>
<td>{i + 1}</td>
<td className="mono">
{t.from_state ?? '∅'} {t.to_state}
</td>
<td>{t.actor_id}</td>
<td>
<span className={`role-chip role-chip--${t.actor_role}`}>{t.actor_role}</span>
</td>
<td>{t.reason ?? '—'}</td>
<td>{new Date(t.occurred_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="dashboard-card">
<div className="card-header">
<h3>Signed event stream</h3>
</div>
{events === null ? (
<div className="loading-row">Loading</div>
) : events.length === 0 ? (
<div className="empty-row">No events.</div>
) : (
<table className="portal-table" data-testid="event-stream">
<thead>
<tr>
<th>#</th>
<th>Type</th>
<th>Signature</th>
<th>Prev hash</th>
<th>At</th>
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.id}>
<td>{e.id}</td>
<td className="mono">{e.type}</td>
<td className="mono truncate">{e.signature}</td>
<td className="mono truncate">{e.prev_hash ?? '∅'}</td>
<td>{new Date(e.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,281 @@
/**
* CurrenciCombo/orchestrator API client — consumed by the portal's
* /transactions page.
*
* When `VITE_ORCHESTRATOR_URL` is unset or the endpoint is unreachable,
* every call falls back to deterministic demo data so the page still
* renders something meaningful. This mirrors how the other services
* treat unreachable backends (chain138, explorer, dbisCore).
*
* The shape of the data matches the orchestrator's API (see
* orchestrator/src/api/plans.ts + orchestrator/src/api/eventBus.ts).
* Re-sync if the orchestrator evolves.
*/
import { endpoints } from '../config/endpoints';
export type TransactionState =
| 'DRAFT'
| 'INITIATED'
| 'PRECONDITIONS_PENDING'
| 'READY_FOR_PREPARE'
| 'PREPARED'
| 'EXECUTING'
| 'PARTIALLY_EXECUTED'
| 'VALIDATING'
| 'COMMITTED'
| 'ABORTED'
| 'UNWIND_PENDING'
| 'CLOSED';
export const TRANSACTION_STATES: TransactionState[] = [
'DRAFT',
'INITIATED',
'PRECONDITIONS_PENDING',
'READY_FOR_PREPARE',
'PREPARED',
'EXECUTING',
'PARTIALLY_EXECUTED',
'VALIDATING',
'COMMITTED',
'ABORTED',
'UNWIND_PENDING',
'CLOSED',
];
export interface PlanSummary {
plan_id: string;
status: TransactionState;
actor_id: string | null;
created_at: string;
updated_at: string;
instrument_hint?: string;
}
export interface StateTransition {
from_state: TransactionState | null;
to_state: TransactionState;
actor_id: string;
actor_role: string;
reason: string | null;
occurred_at: string;
}
export interface PlanStateDetail {
plan_id: string;
current_state: TransactionState;
transitions: StateTransition[];
}
export interface PlanEvent {
id: number;
plan_id: string;
type: string;
payload: Record<string, unknown>;
signature: string;
prev_hash: string | null;
created_at: string;
}
type OrchestratorStatus = 'live' | 'mocked' | 'degraded';
export interface OrchestratorProbe {
status: OrchestratorStatus;
latencyMs: number | null;
error?: string;
}
const base = endpoints.orchestrator.baseUrl;
const deployed = endpoints.orchestrator.deployed;
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, {
...init,
headers: { Accept: 'application/json', ...(init?.headers ?? {}) },
});
if (!res.ok) {
throw new Error(`orchestrator ${res.status} on ${path}`);
}
return res.json() as Promise<T>;
}
export async function probeOrchestrator(): Promise<OrchestratorProbe> {
if (!deployed) return { status: 'mocked', latencyMs: null };
const t0 = performance.now();
try {
const res = await fetch(`${base}/health`, { method: 'GET' });
const latencyMs = Math.round(performance.now() - t0);
if (!res.ok) return { status: 'degraded', latencyMs, error: `HTTP ${res.status}` };
return { status: 'live', latencyMs };
} catch (err) {
return {
status: 'degraded',
latencyMs: null,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function listPlans(): Promise<{ plans: PlanSummary[]; source: OrchestratorStatus }> {
if (!deployed) return { plans: demoPlans(), source: 'mocked' };
try {
const data = await fetchJson<{ plans: PlanSummary[] }>('/api/plans');
return { plans: data.plans ?? [], source: 'live' };
} catch {
return { plans: demoPlans(), source: 'degraded' };
}
}
export async function getPlanState(
planId: string,
): Promise<{ detail: PlanStateDetail; source: OrchestratorStatus }> {
if (!deployed) return { detail: demoPlanState(planId), source: 'mocked' };
try {
const detail = await fetchJson<PlanStateDetail>(`/api/plans/${encodeURIComponent(planId)}/state`);
return { detail, source: 'live' };
} catch {
return { detail: demoPlanState(planId), source: 'degraded' };
}
}
export async function getPlanEvents(
planId: string,
): Promise<{ events: PlanEvent[]; source: OrchestratorStatus }> {
if (!deployed) return { events: demoPlanEvents(planId), source: 'mocked' };
try {
const data = await fetchJson<{ events: PlanEvent[] }>(
`/api/plans/${encodeURIComponent(planId)}/events`,
);
return { events: data.events ?? [], source: 'live' };
} catch {
return { events: demoPlanEvents(planId), source: 'degraded' };
}
}
// ---------------------------------------------------------------------
// Demo data — used only when VITE_ORCHESTRATOR_URL is unset. Lets the
// /transactions page demonstrate the 12-state machine visualisation
// without needing a deployed orchestrator.
// ---------------------------------------------------------------------
function demoPlans(): PlanSummary[] {
const now = Date.now();
return [
{
plan_id: 'demo-sblc-001',
status: 'VALIDATING',
actor_id: 'ops.alice',
created_at: new Date(now - 1000 * 60 * 55).toISOString(),
updated_at: new Date(now - 1000 * 30).toISOString(),
instrument_hint: 'MT760 / EIB beneficiary format',
},
{
plan_id: 'demo-pay-014',
status: 'COMMITTED',
actor_id: 'ops.bob',
created_at: new Date(now - 1000 * 60 * 60 * 3).toISOString(),
updated_at: new Date(now - 1000 * 60 * 7).toISOString(),
instrument_hint: 'pacs.009 FI-to-FI',
},
{
plan_id: 'demo-sblc-003',
status: 'ABORTED',
actor_id: 'ops.alice',
created_at: new Date(now - 1000 * 60 * 60 * 8).toISOString(),
updated_at: new Date(now - 1000 * 60 * 60 * 2).toISOString(),
instrument_hint: 'MT202 COV',
},
{
plan_id: 'demo-draft-029',
status: 'DRAFT',
actor_id: null,
created_at: new Date(now - 1000 * 60 * 4).toISOString(),
updated_at: new Date(now - 1000 * 60 * 4).toISOString(),
instrument_hint: 'Pending review',
},
];
}
function demoPlanState(planId: string): PlanStateDetail {
const plan = demoPlans().find((p) => p.plan_id === planId) ?? demoPlans()[0];
const base = new Date(plan.created_at).getTime();
const mk = (i: number, from: TransactionState | null, to: TransactionState, role: string, actor: string, reason: string) => ({
from_state: from,
to_state: to,
actor_id: actor,
actor_role: role,
reason,
occurred_at: new Date(base + i * 1000 * 60 * 5).toISOString(),
});
if (plan.status === 'COMMITTED') {
return {
plan_id: plan.plan_id,
current_state: 'COMMITTED',
transitions: [
mk(0, null, 'DRAFT', 'submitter', 'ops.bob', 'plan created'),
mk(1, 'DRAFT', 'INITIATED', 'submitter', 'ops.bob', 'initiation'),
mk(2, 'INITIATED', 'PRECONDITIONS_PENDING', 'coordinator', 'system', 'await controls'),
mk(3, 'PRECONDITIONS_PENDING', 'READY_FOR_PREPARE', 'coordinator', 'system', 'preconditions satisfied'),
mk(4, 'READY_FOR_PREPARE', 'PREPARED', 'approver', 'ops.chen', 'approve — prepare (SoD)'),
mk(5, 'PREPARED', 'EXECUTING', 'releaser', 'ops.dey', 'release — execute (SoD)'),
mk(6, 'EXECUTING', 'VALIDATING', 'coordinator', 'system', 'both legs dispatched'),
mk(7, 'VALIDATING', 'COMMITTED', 'validator', 'ops.eve', 'reconciled + committed (SoD)'),
],
};
}
if (plan.status === 'ABORTED') {
return {
plan_id: plan.plan_id,
current_state: 'ABORTED',
transitions: [
mk(0, null, 'DRAFT', 'submitter', 'ops.alice', 'plan created'),
mk(1, 'DRAFT', 'INITIATED', 'submitter', 'ops.alice', 'initiation'),
mk(2, 'INITIATED', 'PRECONDITIONS_PENDING', 'coordinator', 'system', 'await controls'),
mk(3, 'PRECONDITIONS_PENDING', 'READY_FOR_PREPARE', 'coordinator', 'system', 'preconditions satisfied'),
mk(4, 'READY_FOR_PREPARE', 'PREPARED', 'approver', 'ops.chen', 'approve — prepare (SoD)'),
mk(5, 'PREPARED', 'EXECUTING', 'releaser', 'ops.dey', 'release — execute (SoD)'),
mk(6, 'EXECUTING', 'VALIDATING', 'coordinator', 'system', 'both legs dispatched'),
mk(7, 'VALIDATING', 'ABORTED', 'validator', 'ops.eve', 'amount mismatch on camt.054'),
],
};
}
if (plan.status === 'DRAFT') {
return {
plan_id: plan.plan_id,
current_state: 'DRAFT',
transitions: [mk(0, null, 'DRAFT', 'submitter', 'ops.frank', 'plan created')],
};
}
return {
plan_id: plan.plan_id,
current_state: 'VALIDATING',
transitions: [
mk(0, null, 'DRAFT', 'submitter', 'ops.alice', 'plan created'),
mk(1, 'DRAFT', 'INITIATED', 'submitter', 'ops.alice', 'initiation'),
mk(2, 'INITIATED', 'PRECONDITIONS_PENDING', 'coordinator', 'system', 'await controls'),
mk(3, 'PRECONDITIONS_PENDING', 'READY_FOR_PREPARE', 'coordinator', 'system', 'preconditions satisfied'),
mk(4, 'READY_FOR_PREPARE', 'PREPARED', 'approver', 'ops.chen', 'approve — prepare (SoD)'),
mk(5, 'PREPARED', 'EXECUTING', 'releaser', 'ops.dey', 'release — execute (SoD)'),
mk(6, 'EXECUTING', 'VALIDATING', 'coordinator', 'system', 'both legs dispatched, awaiting reconciliation'),
],
};
}
function demoPlanEvents(planId: string): PlanEvent[] {
const detail = demoPlanState(planId);
return detail.transitions.map((t, i) => ({
id: i + 1,
plan_id: planId,
type: `state.${t.to_state.toLowerCase()}`,
payload: {
from: t.from_state,
to: t.to_state,
actor_role: t.actor_role,
reason: t.reason,
},
signature: `demo-sig-${i.toString(16).padStart(4, '0')}`,
prev_hash: i === 0 ? null : `demo-hash-${(i - 1).toString(16).padStart(4, '0')}`,
created_at: t.occurred_at,
}));
}